improvement: all confirmed suggestions implemented

This commit is contained in:
2026-09-04 17:04:48 +03:30
parent 7f92e735fb
commit 72f885d9dd
23 changed files with 1070 additions and 679 deletions

View File

@@ -2,36 +2,48 @@
import type { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
import {
addonLeaves,
catalogByCode,
categoryCodesInCatalog,
categoryDisabledForJobs,
categoryTileColor,
crownRestorationCode,
leavesFor,
subcategoriesFor,
type AddonSlot,
type ArchTarget,
type PickerScope,
} from '@/components/treatment/prosthesisTree';
const CHIP_INK = '#14253d';
const TILE_CLASS =
'flex h-full min-h-0 w-[3.6rem] shrink-0 flex-row items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-0.5 py-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40';
'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';
const TILE_TEXT_CLASS =
'max-h-full overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
const HEADER_BTN_CLASS = 'h-auto min-h-8 px-2.5 py-1 text-xs leading-tight';
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
const TILE_GRID_CLASS =
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,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) {
return active
? 'border-primary ring-1 ring-primary/30'
: 'border-border/70 hover:border-border hover:bg-background-card/70';
}
export type PickerStep =
| { kind: 'category' }
| { kind: 'subcategory'; category: string; arch?: ArchTarget }
| { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget }
| { kind: 'addon'; slot: 'implant' | 'post_core' };
| { kind: 'addon'; slot: AddonSlot };
interface ProsthesisJobPopoverProps {
open: boolean;
@@ -42,13 +54,11 @@ interface ProsthesisJobPopoverProps {
lockedArch?: ArchTarget;
jobCodes: string[];
tooth: string | null;
rangeLabel?: string | null;
allowImplantAddon: boolean;
allowPostCoreAddon: boolean;
allowCrownAddon: boolean;
onStep: (step: PickerStep) => void;
onLockedArchChange?: (arch: ArchTarget) => void;
onPickLeaf: (code: string, arch?: ArchTarget) => void;
onPickAddon: (code: string | null, kind: 'implant' | 'post_core') => void;
onPickAddon: (code: string | null, kind: AddonSlot) => void;
onRemoveJob: (code: string) => void;
onClear: () => void;
onClose: () => void;
@@ -56,54 +66,47 @@ interface ProsthesisJobPopoverProps {
function Tile({
label,
caption,
color,
active,
disabled,
hint,
className = TILE_CLASS,
onClick,
}: {
label: string;
caption?: string;
color?: string;
active?: boolean;
disabled?: boolean;
hint?: string;
className?: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={caption ? `${caption}: ${label}` : label}
title={hint ?? label}
onClick={onClick}
className={`${TILE_CLASS} ${
active
? 'border-primary ring-1 ring-primary/30'
: 'border-border/70 hover:border-border hover:bg-background-card/70'
}`}
disabled={disabled}
className={`${className} ${tileSurfaceClass(active)}`}
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
>
{caption ? (
<span className="max-h-full max-w-[0.7rem] shrink-0 overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-[10px] font-medium leading-tight opacity-70">
{caption}
</span>
) : null}
<span className={TILE_TEXT_CLASS}>{label}</span>
</button>
);
}
function VRule() {
return <div className="w-px shrink-0 self-stretch bg-border-strong" aria-hidden />;
}
function parentStep(step: PickerStep): PickerStep | null {
if (step.kind === 'category') return null;
if (step.kind === 'addon') return { kind: 'category' };
if (step.kind === 'leaves') {
if (step.subcategory) {
return { kind: 'subcategory', category: step.category, arch: step.arch };
}
return { kind: 'category' };
switch (step.kind) {
case 'category':
return null;
case 'addon':
case 'subcategory':
return { kind: 'category' };
case 'leaves':
return step.subcategory
? { kind: 'subcategory', category: step.category, arch: step.arch }
: { kind: 'category' };
}
if (step.kind === 'subcategory') return { kind: 'category' };
return { kind: 'category' };
}
export function ProsthesisJobPopover({
@@ -115,9 +118,7 @@ export function ProsthesisJobPopover({
lockedArch,
jobCodes,
tooth,
rangeLabel,
allowImplantAddon,
allowPostCoreAddon,
allowCrownAddon,
onStep,
onLockedArchChange,
onPickLeaf,
@@ -130,14 +131,10 @@ export function ProsthesisJobPopover({
if (!open) return null;
const categories = categoryCodesInCatalog(catalog, scope);
const implantAddons = addonLeaves(catalog, 'implant');
const postCoreAddons = addonLeaves(catalog, 'post_core');
const currentImplant =
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'implant') ?? '';
const currentPost =
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'post_core') ?? '';
const byCode = catalogByCode(catalog);
const crownLeaves = leavesFor(catalog, 'crown', undefined, 'tooth');
const currentCrown = crownRestorationCode(jobCodes, byCode) ?? '';
const hasLeaf = jobCodes.length > 0;
const byCode = new Map(catalog.map((e) => [e.code, e]));
const archForLeaves =
step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch;
@@ -151,6 +148,7 @@ export function ProsthesisJobPopover({
const back = parentStep(step);
function openCategory(code: string) {
if (categoryDisabledForJobs(code, jobCodes, byCode)) return;
const subs = subcategoriesFor(catalog, code, scope);
const arch = scope === 'arch' ? lockedArch : undefined;
if (subs.length > 0) {
@@ -173,53 +171,38 @@ export function ProsthesisJobPopover({
}
const addonTiles: ReactNode[] = [];
if (scope === 'tooth' && hasLeaf) {
if (allowImplantAddon && implantAddons.length > 0) {
if (scope === 'tooth' && allowCrownAddon && crownLeaves.length > 0) {
const addonActive = step.kind === 'addon' && step.slot === 'crown';
const openAddon = () =>
onStep(addonActive ? { kind: 'category' } : { kind: 'addon', slot: 'crown' });
if (currentCrown) {
addonTiles.push(
<Tile
key="slot-implant"
caption={t('slotImplant')}
label={
currentImplant
? (byCode.get(currentImplant)?.label ?? currentImplant)
: t('addonNone')
}
color={
currentImplant
? prosthesisTypeColorFromCatalog(currentImplant, catalog)
: undefined
}
active={step.kind === 'addon' && step.slot === 'implant'}
onClick={() =>
onStep(
step.kind === 'addon' && step.slot === 'implant'
? { kind: 'category' }
: { kind: 'addon', slot: 'implant' },
)
}
key="slot-crown"
className={`${TILE_CLASS} h-full`}
label={byCode.get(currentCrown)?.label ?? currentCrown}
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
active={addonActive}
onClick={openAddon}
/>,
);
}
if (allowPostCoreAddon && postCoreAddons.length > 0) {
} else {
addonTiles.push(
<Tile
key="slot-post"
caption={t('slotPostCore')}
label={
currentPost ? (byCode.get(currentPost)?.label ?? currentPost) : t('addonNone')
}
color={
currentPost ? prosthesisTypeColorFromCatalog(currentPost, catalog) : undefined
}
active={step.kind === 'addon' && step.slot === 'post_core'}
onClick={() =>
onStep(
step.kind === 'addon' && step.slot === 'post_core'
? { kind: 'category' }
: { kind: 'addon', slot: 'post_core' },
)
}
/>,
<button
key="slot-crown"
type="button"
title={t('addonCrownCanBeAdded')}
aria-pressed={addonActive}
onClick={openAddon}
className={`${TILE_CLASS} h-full ${tileSurfaceClass(addonActive)}`}
>
<span className="inline-flex max-w-full items-center justify-center gap-1 rounded-[var(--radius-md)] border border-dashed border-border px-3 py-1.5 text-sm font-medium text-text-primary">
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="whitespace-normal break-words text-center leading-tight">
{t('addonCrownCanBeAdded')}
</span>
</span>
</button>,
);
}
}
@@ -228,103 +211,155 @@ export function ProsthesisJobPopover({
let childTiles: ReactNode[] = [];
let childrenKey = 'root';
if (step.kind === 'category') {
childTiles = categories.map((code) => (
<Tile
key={code}
label={categoryLabel(code)}
color={categoryTileColor(code)}
onClick={() => openCategory(code)}
/>
));
} else if (step.kind === 'addon') {
selectedNode = {
label: step.slot === 'implant' ? t('slotImplant') : t('slotPostCore'),
};
childrenKey = `addon-${step.slot}`;
const options = step.slot === 'implant' ? implantAddons : postCoreAddons;
const current = step.slot === 'implant' ? currentImplant : currentPost;
childTiles = [
<Tile
key="none"
label={t('addonNone')}
active={!current}
onClick={() => onPickAddon(null, step.slot)}
/>,
...options.map((opt) => (
switch (step.kind) {
case 'category':
childTiles = categories.map((code) => {
const disabled = categoryDisabledForJobs(code, jobCodes, byCode);
return (
<Tile
key={code}
label={categoryLabel(code)}
color={categoryTileColor(code)}
disabled={disabled}
hint={disabled ? t('categoryDisabledHint') : undefined}
onClick={() => openCategory(code)}
/>
);
});
break;
case 'addon':
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
childrenKey = 'addon-crown';
childTiles = [
<Tile
key={opt.code}
label={opt.label}
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
active={current === opt.code}
onClick={() => onPickAddon(opt.code, step.slot)}
/>
)),
];
} else if (step.kind === 'leaves' && step.subcategory) {
selectedNode = { label: subLabel(step.subcategory) };
childrenKey = `leaves-${step.category}-${step.subcategory}`;
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
color: categoryTileColor(step.category),
};
childrenKey = `${step.kind}-${step.category}`;
const arch = step.arch;
const subs =
step.kind === 'subcategory'
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
<Tile
key={sub}
label={subLabel(sub)}
onClick={() =>
onStep({
kind: 'leaves',
category: step.category,
subcategory: sub,
arch,
})
}
/>
))
: [];
const leaves = leavesFor(
catalog,
step.category,
step.kind === 'leaves' ? step.subcategory : undefined,
scope,
).map(leafTile);
childTiles = [...subs, ...leaves];
key="none"
label={t('addonNone')}
active={!currentCrown}
onClick={() => onPickAddon(null, 'crown')}
/>,
...crownLeaves.map((opt) => (
<Tile
key={opt.code}
label={opt.label}
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
active={currentCrown === opt.code}
onClick={() => onPickAddon(opt.code, 'crown')}
/>
)),
];
break;
case 'subcategory': {
const parentFill = categoryTileColor(step.category);
selectedNode = { label: categoryLabel(step.category), color: parentFill };
childrenKey = `sub-${step.category}`;
childTiles = [
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
<Tile
key={sub}
label={subLabel(sub)}
color={prosthesisFamilyTone(parentFill, i + 1)}
onClick={() =>
onStep({
kind: 'leaves',
category: step.category,
subcategory: sub,
arch: step.arch,
})
}
/>
)),
...leavesFor(catalog, step.category, undefined, scope).map(leafTile),
];
break;
}
case 'leaves':
if (step.subcategory) {
selectedNode = {
label: subLabel(step.subcategory),
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
};
childrenKey = `leaves-${step.category}-${step.subcategory}`;
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
color: categoryTileColor(step.category),
};
childrenKey = `leaves-${step.category}`;
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
}
break;
}
const tileRow = (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-row items-stretch gap-2 overflow-x-auto overflow-y-hidden pe-1">
{selectedNode ? (
<div className="flex h-full min-h-0 shrink-0 flex-row items-stretch gap-4">
<Tile
label={selectedNode.label}
color={selectedNode.color}
active
onClick={() => back && onStep(back)}
/>
<VRule />
<div
key={childrenKey}
className="picker-expand flex h-full min-h-0 shrink-0 flex-row items-stretch gap-2"
>
{childTiles}
</div>
</div>
) : (
childTiles
)}
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)]'
}`}
>
{childTiles}
</div>
);
const title = rangeLabel
? t('pickerTeeth', { teeth: rangeLabel })
: scope === 'arch'
const branchColor = selectedNode?.color ?? 'var(--color-border-strong)';
const tileRow = selectedNode ? (
<div className="flex min-h-0 flex-col gap-2">
<button
type="button"
onClick={() => {
if (back) onStep(back);
}}
disabled={!back}
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
style={
selectedNode.color
? { backgroundColor: selectedNode.color, color: CHIP_INK }
: undefined
}
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
}
aria-hidden
/>
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
</button>
<div className="flex items-start">
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
<span
className="absolute w-0.5"
style={{
backgroundColor: branchColor,
insetInlineStart: 'calc(50% - 1px)',
top: 0,
height: '50%',
}}
/>
<span
className="absolute h-0.5"
style={{
backgroundColor: branchColor,
top: 'calc(50% - 1px)',
insetInlineStart: '50%',
width: '50%',
}}
/>
</div>
{tileGrid}
</div>
</div>
) : (
tileGrid
);
const title =
scope === 'arch'
? lockedArch === 'both'
? t('pickerArchBoth')
: lockedArch === 'lower'
@@ -334,11 +369,90 @@ export function ProsthesisJobPopover({
? t('pickerTooth', { tooth })
: t('pickerTitle');
const header = (
<div className="space-y-1.5 shrink-0">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 text-xs font-semibold text-text-primary">{title}</p>
<div className="flex shrink-0 items-center gap-1.5">
const jobChips =
jobCodes.length === 0 ? (
<p className="text-xs text-text-muted">{t('noneYet')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{jobCodes.map((code) => {
const entry = byCode.get(code);
const color = prosthesisTypeColorFromCatalog(code, catalog);
const trashColor = prosthesisTypeFillAccent(color);
return (
<span
key={code}
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
style={{
backgroundColor: color,
color: CHIP_INK,
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>
<button
type="button"
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
style={{
borderColor: trashColor,
color: trashColor,
}}
aria-label={t('removeJob')}
onClick={(e) => {
e.stopPropagation();
onRemoveJob(code);
}}
>
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);
})}
</div>
);
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">
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
{scope === 'arch' && onLockedArchChange ? (
<div
className="flex h-8 w-1/2 shrink-0 rounded-[var(--radius-md)] border border-border p-0.5"
role="group"
aria-label={title}
>
{(['upper', 'lower', 'both'] as const).map((arch) => (
<button
key={arch}
type="button"
onClick={() => onLockedArchChange(arch)}
className={`h-full min-w-0 flex-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium leading-tight ${
lockedArch === arch
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{t(`arch_${arch}`)}
</button>
))}
</div>
) : null}
<div className="shrink-0">{jobChips}</div>
<p className="shrink-0 text-[10px] leading-tight text-text-muted">
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
</p>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-0.5">
{tileRow}
</div>
</section>
<section className="flex min-h-0 min-w-0 flex-col gap-1">
<div className="flex shrink-0 flex-col gap-1">
<Button
type="button"
variant="outline"
@@ -359,91 +473,15 @@ export function ProsthesisJobPopover({
{t('done')}
</Button>
</div>
</div>
{scope === 'arch' && onLockedArchChange ? (
<div className="flex gap-1">
{(['upper', 'lower', 'both'] as const).map((arch) => (
<button
key={arch}
type="button"
onClick={() => onLockedArchChange(arch)}
className={`flex-1 min-h-9 rounded-[var(--radius-sm)] border text-xs font-medium ${
lockedArch === arch
? 'border-primary bg-primary-soft text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border'
}`}
>
{t(`arch_${arch}`)}
</button>
))}
</div>
) : null}
{jobCodes.length === 0 ? (
<p className="text-xs text-text-muted">{t('noneYet')}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{jobCodes.map((code) => {
const entry = byCode.get(code);
const color = prosthesisTypeColorFromCatalog(code, catalog);
const trashColor = prosthesisTypeFillAccent(color);
return (
<span
key={code}
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
style={{
backgroundColor: color,
color: CHIP_INK,
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>
<button
type="button"
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
style={{
borderColor: trashColor,
color: trashColor,
}}
aria-label={t('removeJob')}
onClick={(e) => {
e.stopPropagation();
onRemoveJob(code);
}}
>
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);
})}
</div>
)}
<p className="text-[10px] text-text-muted">
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
</p>
</div>
);
const addonRail =
addonTiles.length > 0 ? (
<div className="flex shrink-0 flex-row items-stretch gap-2 border-s border-border ps-3">
{addonTiles}
</div>
) : null;
const body = (
<div className="flex h-full min-h-0 flex-col gap-3">
{header}
<div className="flex min-h-0 flex-1 flex-row items-stretch gap-0">
{tileRow}
{addonRail}
</div>
{addonTiles.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-1">
<p className="shrink-0 text-[10px] font-medium leading-tight text-text-muted">
{t('suggestionsLabel')}
</p>
<div className="min-h-0 flex-1">{addonTiles}</div>
</div>
) : null}
</section>
</div>
);
@@ -453,7 +491,7 @@ export function ProsthesisJobPopover({
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
maxWidthClass="sm:max-w-lg"
maxWidthClass="sm:max-w-2xl"
className="max-h-[90dvh] flex flex-col"
>
<h4 className="sr-only">{t('pickerTitle')}</h4>
@@ -472,7 +510,7 @@ export function ProsthesisJobPopover({
onClick={onClose}
/>
<div
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary px-3 pb-3 pt-2 shadow-lg"
role="dialog"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}