improvement: icons added to prosthesis types catalog.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
|
||||
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogIconSrc } from '@/components/treatment/prosthesisCatalogIcons';
|
||||
import {
|
||||
PROSTHESIS_JOB_PATH_SEP,
|
||||
prosthesisJobPathParts,
|
||||
} from '@/components/treatment/prosthesisJobPath';
|
||||
import {
|
||||
catalogByCode,
|
||||
categoryCodesInCatalog,
|
||||
@@ -23,14 +28,24 @@ import {
|
||||
type ArchTarget,
|
||||
type PickerScope,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import { ProsthesisCatalogIcon } from '@/components/ui/treatment/ProsthesisCatalogIcon';
|
||||
|
||||
const CHIP_INK = '#14253d';
|
||||
const PARENT_BACK_INK = '#000000';
|
||||
const TILE_CLASS =
|
||||
'flex min-h-[3.25rem] w-full flex-col items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
'flex min-h-[4.5rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const CHILD_TILE_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2 py-1 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const PARENT_BAR_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-row items-center justify-start gap-2 rounded-[var(--radius-md)] border px-3 py-1.5 text-start transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const TILE_TEXT_CLASS =
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
|
||||
const TILE_TEXT_CHILD_CLASS =
|
||||
'w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-center text-[11px] font-medium leading-tight sm:text-xs';
|
||||
const TILE_GRID_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]';
|
||||
const TILE_GRID_CHILD_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(10.2rem,1fr))]';
|
||||
const HEADER_BTN_CLASS = 'h-auto min-h-8 w-full px-2 py-1 text-xs leading-tight whitespace-normal';
|
||||
|
||||
function tileSurfaceClass(active?: boolean) {
|
||||
@@ -70,7 +85,9 @@ function Tile({
|
||||
active,
|
||||
disabled,
|
||||
hint,
|
||||
className = TILE_CLASS,
|
||||
iconSrc,
|
||||
compact,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -78,19 +95,23 @@ function Tile({
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
iconSrc?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = className ?? (compact ? CHILD_TILE_CLASS : TILE_CLASS);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={hint ?? label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${className} ${tileSurfaceClass(active)}`}
|
||||
className={`${base} ${compact ? 'gap-0.5' : 'gap-2'} ${tileSurfaceClass(active)}`}
|
||||
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
|
||||
>
|
||||
<span className={TILE_TEXT_CLASS}>{label}</span>
|
||||
{iconSrc ? <ProsthesisCatalogIcon src={iconSrc} /> : null}
|
||||
<span className={compact ? TILE_TEXT_CHILD_CLASS : TILE_TEXT_CLASS}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +130,19 @@ function parentStep(step: PickerStep): PickerStep | null {
|
||||
}
|
||||
}
|
||||
|
||||
function pickerStepKey(step: PickerStep): string {
|
||||
switch (step.kind) {
|
||||
case 'category':
|
||||
return 'category';
|
||||
case 'addon':
|
||||
return `addon:${step.slot}`;
|
||||
case 'subcategory':
|
||||
return `sub:${step.category}:${step.arch ?? ''}`;
|
||||
case 'leaves':
|
||||
return `leaves:${step.category}:${step.subcategory ?? ''}:${step.arch ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProsthesisJobPopover({
|
||||
open,
|
||||
mobile,
|
||||
@@ -128,6 +162,31 @@ export function ProsthesisJobPopover({
|
||||
onClose,
|
||||
}: ProsthesisJobPopoverProps) {
|
||||
const t = useTranslations('prosthesis');
|
||||
const childGridRef = useRef<HTMLDivElement>(null);
|
||||
const [branchH, setBranchH] = useState(0);
|
||||
const stepKey = pickerStepKey(step);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || stepKey === 'category') return;
|
||||
const grid = childGridRef.current;
|
||||
if (!grid) return;
|
||||
|
||||
const sync = () => {
|
||||
const first = grid.firstElementChild;
|
||||
if (!(first instanceof HTMLElement)) return;
|
||||
setBranchH(first.offsetHeight);
|
||||
};
|
||||
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(grid);
|
||||
grid.addEventListener('animationend', sync);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
grid.removeEventListener('animationend', sync);
|
||||
};
|
||||
}, [open, stepKey]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const categories = categoryCodesInCatalog(catalog, scope);
|
||||
@@ -159,10 +218,15 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
|
||||
function leafTile(leaf: ProsthesisCatalogEntry) {
|
||||
const path = prosthesisJobPathParts(leaf.code, catalog, (key) => t(key as never));
|
||||
const underIndirectSub =
|
||||
leaf.category === 'indirect' && step.kind === 'leaves' && Boolean(step.subcategory);
|
||||
return (
|
||||
<Tile
|
||||
key={leaf.code}
|
||||
label={leaf.label}
|
||||
label={underIndirectSub ? path.leaf : leaf.label}
|
||||
compact
|
||||
iconSrc={prosthesisCatalogIconSrc(leaf)}
|
||||
color={prosthesisTypeColorFromCatalog(leaf.code, catalog)}
|
||||
active={jobCodes.includes(leaf.code)}
|
||||
onClick={() => onPickLeaf(leaf.code, archForLeaves)}
|
||||
@@ -180,7 +244,11 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key="slot-crown"
|
||||
className={`${TILE_CLASS} h-full`}
|
||||
compact
|
||||
label={byCode.get(currentCrown)?.label ?? currentCrown}
|
||||
iconSrc={prosthesisCatalogIconSrc(
|
||||
byCode.get(currentCrown) ?? { code: currentCrown, category: 'crown' },
|
||||
)}
|
||||
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
|
||||
active={addonActive}
|
||||
onClick={openAddon}
|
||||
@@ -207,7 +275,7 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
}
|
||||
|
||||
let selectedNode: { label: string; color?: string } | null = null;
|
||||
let selectedNode: { label: string; color?: string; iconSrc?: string } | null = null;
|
||||
let childTiles: ReactNode[] = [];
|
||||
let childrenKey = 'root';
|
||||
|
||||
@@ -219,6 +287,7 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key={code}
|
||||
label={categoryLabel(code)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: code })}
|
||||
color={categoryTileColor(code)}
|
||||
disabled={disabled}
|
||||
hint={disabled ? t('categoryDisabledHint') : undefined}
|
||||
@@ -228,11 +297,16 @@ export function ProsthesisJobPopover({
|
||||
});
|
||||
break;
|
||||
case 'addon':
|
||||
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
|
||||
selectedNode = {
|
||||
label: t('slotCrown'),
|
||||
color: categoryTileColor('crown'),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: 'crown' }),
|
||||
};
|
||||
childrenKey = 'addon-crown';
|
||||
childTiles = [
|
||||
<Tile
|
||||
key="none"
|
||||
compact
|
||||
label={t('addonNone')}
|
||||
active={!currentCrown}
|
||||
onClick={() => onPickAddon(null, 'crown')}
|
||||
@@ -240,7 +314,9 @@ export function ProsthesisJobPopover({
|
||||
...crownLeaves.map((opt) => (
|
||||
<Tile
|
||||
key={opt.code}
|
||||
compact
|
||||
label={opt.label}
|
||||
iconSrc={prosthesisCatalogIconSrc(opt)}
|
||||
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
||||
active={currentCrown === opt.code}
|
||||
onClick={() => onPickAddon(opt.code, 'crown')}
|
||||
@@ -250,13 +326,19 @@ export function ProsthesisJobPopover({
|
||||
break;
|
||||
case 'subcategory': {
|
||||
const parentFill = categoryTileColor(step.category);
|
||||
selectedNode = { label: categoryLabel(step.category), color: parentFill };
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: parentFill,
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `sub-${step.category}`;
|
||||
childTiles = [
|
||||
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
|
||||
<Tile
|
||||
key={sub}
|
||||
compact
|
||||
label={subLabel(sub)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: step.category, subcategory: sub })}
|
||||
color={prosthesisFamilyTone(parentFill, i + 1)}
|
||||
onClick={() =>
|
||||
onStep({
|
||||
@@ -277,6 +359,10 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: subLabel(step.subcategory),
|
||||
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
|
||||
iconSrc: prosthesisCatalogIconSrc({
|
||||
category: step.category,
|
||||
subcategory: step.subcategory,
|
||||
}),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}-${step.subcategory}`;
|
||||
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
|
||||
@@ -284,6 +370,7 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: categoryTileColor(step.category),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}`;
|
||||
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
|
||||
@@ -294,9 +381,10 @@ export function ProsthesisJobPopover({
|
||||
const tileGrid = (
|
||||
<div
|
||||
key={childrenKey}
|
||||
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
|
||||
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
|
||||
}`}
|
||||
ref={childGridRef}
|
||||
className={`picker-expand min-w-0 flex-1 ${
|
||||
selectedNode ? TILE_GRID_CHILD_CLASS : TILE_GRID_CLASS
|
||||
}${selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(4.5rem,1fr)]'}`}
|
||||
>
|
||||
{childTiles}
|
||||
</div>
|
||||
@@ -311,7 +399,7 @@ export function ProsthesisJobPopover({
|
||||
if (back) onStep(back);
|
||||
}}
|
||||
disabled={!back}
|
||||
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
|
||||
className={PARENT_BAR_CLASS}
|
||||
style={
|
||||
selectedNode.color
|
||||
? { backgroundColor: selectedNode.color, color: CHIP_INK }
|
||||
@@ -320,18 +408,19 @@ export function ProsthesisJobPopover({
|
||||
aria-label={t('pickerBack')}
|
||||
>
|
||||
<ArrowLeft
|
||||
className="lucide-inherit h-4 w-4 shrink-0 rtl:rotate-180"
|
||||
style={
|
||||
selectedNode.color
|
||||
? { color: prosthesisTypeFillAccent(selectedNode.color) }
|
||||
: undefined
|
||||
}
|
||||
className="h-4 w-4 shrink-0 rtl:rotate-180"
|
||||
style={{ color: PARENT_BACK_INK }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
|
||||
{selectedNode.iconSrc ? (
|
||||
<ProsthesisCatalogIcon src={selectedNode.iconSrc} size="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 whitespace-normal break-words text-start text-xs font-medium leading-tight sm:text-[13px]">
|
||||
{selectedNode.label}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-start">
|
||||
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
|
||||
<div className="relative w-3 shrink-0" style={{ height: branchH || undefined }} aria-hidden>
|
||||
<span
|
||||
className="absolute w-0.5"
|
||||
style={{
|
||||
@@ -375,7 +464,7 @@ export function ProsthesisJobPopover({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{jobCodes.map((code) => {
|
||||
const entry = byCode.get(code);
|
||||
const path = prosthesisJobPathParts(code, catalog, (key) => t(key as never));
|
||||
const color = prosthesisTypeColorFromCatalog(code, catalog);
|
||||
const trashColor = prosthesisTypeFillAccent(color);
|
||||
return (
|
||||
@@ -388,13 +477,14 @@ export function ProsthesisJobPopover({
|
||||
borderColor: 'rgba(0,0,0,0.16)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5">
|
||||
<span className="opacity-70">
|
||||
{entry?.category
|
||||
? t(`category_${entry.category}` as never)
|
||||
: t('regionCrown')}
|
||||
</span>
|
||||
{entry?.label ?? code}
|
||||
<span className="inline-flex items-center px-2 py-0.5">
|
||||
{path.ancestors.length > 0 ? (
|
||||
<span className="opacity-70">
|
||||
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
|
||||
{PROSTHESIS_JOB_PATH_SEP}
|
||||
</span>
|
||||
) : null}
|
||||
{path.leaf}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -418,8 +508,8 @@ export function ProsthesisJobPopover({
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr] gap-x-3">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-2.5">
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr]">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-2.5 border-e border-border pe-3">
|
||||
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
|
||||
{scope === 'arch' && onLockedArchChange ? (
|
||||
<div
|
||||
@@ -451,7 +541,7 @@ export function ProsthesisJobPopover({
|
||||
{tileRow}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1 ps-3">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user