improvement: new prosthesis type data structure implemented and finally working!

This commit is contained in:
2026-09-01 21:03:04 +03:30
parent dc71c73ced
commit a3c14a18c1
51 changed files with 3306 additions and 1117 deletions

View File

@@ -1,28 +1,26 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { toothJobRowsForDetail } from '@/components/treatment/prosthesisTree';
import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
import {
applyShiftRange,
deriveTeethFromGroups,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
linkAdjacentTeeth,
normalizeToothSelectionGroups,
pruneToothProsthesisForGroups,
toggleToothInGroups,
toothEdgeKey,
unlinkAdjacentTeeth,
type ToothSelectionGroup,
} from '@/components/treatment/toothSelectionGroups';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { Button } from '@/components/ui/shared/Button';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ProsthesisAssignChart } from '@/components/ui/treatment/ProsthesisAssignChart';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { casesApi } from '@/lib/api/cases';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
@@ -135,12 +133,10 @@ export function CaseCreatePanel({
);
const [partners, setPartners] = useState<LinkedOrganizationOption[]>([]);
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const hydratedIdRef = useRef(labCase.id);
const skipSaveRef = useRef(true);
const startingRef = useRef(false);
@@ -173,23 +169,13 @@ export function CaseCreatePanel({
activeLine?.toothSelectionGroups.length
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine?.teeth ?? []);
const selected = new Set(activeLine?.teeth ?? []);
const linkedEdges = linkedEdgesFromGroups(groups);
const prosthesisRows = groups.map((g) => ({
groupId: g.groupId,
kind: g.kind,
teeth: g.teeth,
}));
const toothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
for (const tp of activeLine?.toothProsthesis ?? []) {
const color = prosthesisTypeColorFromCatalog(tp.prosthesisTypeCode, prosthesisOptions);
if (color) colors[tp.tooth as FdiToothId] = color;
}
return colors;
}, [activeLine?.toothProsthesis, prosthesisOptions]);
const connectedGroupIds = new Set(
groups.filter((group) => group.kind === 'connected').map((group) => group.groupId),
);
const toothJobRows = activeLine
? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId, connectedGroupIds)
: [];
const buildPayload = useCallback(
() => ({
@@ -489,42 +475,12 @@ export function CaseCreatePanel({
{activeLine ? (
<>
<FdiToothChart
selected={selected}
linkedEdges={linkedEdges}
toothColors={toothColors}
<ProsthesisAssignChart
groups={groups}
toothProsthesis={activeLine.toothProsthesis}
detailClientId={activeLine.clientId}
catalog={prosthesisOptions}
disabled={disabled}
onToggle={(fdi, event) => {
if (disabled) return;
const currentGroups =
activeLine.toothSelectionGroups.length > 0
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine.teeth);
let nextGroups: ToothSelectionGroup[] | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
} else {
nextGroups = toggleToothInGroups(currentGroups, fdi);
rangeAnchorRef.current = fdi;
}
if (!nextGroups) return;
updateActiveLine((line) => ({
...line,
toothSelectionGroups: nextGroups!,
teeth: deriveTeethFromGroups(nextGroups!),
toothProsthesis: pruneToothProsthesisForGroups(
line.toothProsthesis,
line.clientId,
nextGroups!,
),
}));
}}
onToggleLink={(a, b) => {
if (disabled) return;
const currentGroups =
@@ -547,111 +503,30 @@ export function CaseCreatePanel({
),
}));
}}
onChange={({ groups: nextGroups, toothProsthesis }) => {
updateActiveLine((line) => ({
...line,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
toothProsthesis,
}));
}}
/>
{prosthesisRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-medium text-text-secondary">
{tTreatment('prosthesisTypesTitle')}
</p>
{prosthesisRows.every((r) => r.kind === 'single') &&
prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{tTreatment('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (!code) return;
updateActiveLine((line) => ({
...line,
toothProsthesis: prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
detailClientId: line.clientId,
})),
),
}));
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{tTreatment('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
) : null}
<div className="space-y-3">
{prosthesisRows.map((row) => {
const current =
activeLine.toothProsthesis.find(
(tp) =>
tp.selectionGroupId === row.groupId &&
(row.teeth as string[]).includes(tp.tooth),
)?.prosthesisTypeCode ??
activeLine.toothProsthesis.find((tp) =>
(row.teeth as string[]).includes(tp.tooth),
)?.prosthesisTypeCode ??
'';
return (
<label
key={row.groupId}
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
>
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span>
{row.kind === 'connected'
? tTreatment('prosthesisConnectedLabel')
: tTreatment('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => {
const code = e.target.value;
updateActiveLine((line) => {
const rest = line.toothProsthesis.filter(
(tp) => !(row.teeth as string[]).includes(tp.tooth),
);
const next = code
? row.teeth.map((tooth) => ({
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
detailClientId: line.clientId,
}))
: [];
return { ...line, toothProsthesis: [...rest, ...next] };
});
}}
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
>
<option value="">{tTreatment('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</span>
</label>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">
{tTreatment('prosthesisTypesTitle')}
</p>
<p className="text-[11px] text-text-muted">{tTreatment('prosthesisEditOnChart')}</p>
{toothJobRows.length === 0 ? (
<p className="text-xs text-amber-700">{tTreatment('prosthesisMissingOnChart')}</p>
) : (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={prosthesisOptions}
/>
)}
</div>
<label className="block text-xs font-medium text-text-muted">
{tTreatment('comments')}

View File

@@ -146,6 +146,8 @@ export function CaseDetailPanel({
toothChart: t('caseSheetToothChart'),
connected: tTreatment('connectedBadge'),
teeth: t('teethLabel'),
archUpper: tTreatment('selectedArchUpper'),
archLower: tTreatment('selectedArchLower'),
comments: t('caseSheetComments'),
noComments: t('caseSheetNoComments'),
},
@@ -323,7 +325,10 @@ export function CaseDetailPanel({
</Badge>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth),
teeth: formatToothList(group.teeth, {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
}),
prosthesis: group.prosthesisTypeLabel,
})}
</span>

View File

@@ -27,6 +27,8 @@ export type CaseSheetLabels = {
toothChart: string;
connected: string;
teeth: string;
archUpper: string;
archLower: string;
comments: string;
noComments: string;
};
@@ -193,7 +195,7 @@ export function CaseSheetPrintLayout({
) : null}
</div>
<p style={{ margin: '4px 0 0', fontSize: 11, color: '#475569' }}>
{labels.teeth}: {formatToothList(row.teeth)}
{labels.teeth}: {formatToothList(row.teeth, { UA: labels.archUpper, LA: labels.archLower })}
</p>
</li>
);

View File

@@ -2,7 +2,8 @@
import { useMemo } from 'react';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { prosthesisTypeColor, prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { toothRegionColors } from '@/components/treatment/prosthesisTree';
import { prosthesisTypeColor, splitProsthesisGroupCode } from '@/components/treatment/prosthesisTypeDisplay';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId } from '@/types/treatment';
@@ -47,17 +48,23 @@ export function CaseToothChartPanel({
return set;
}, [details]);
const toothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
const regionColors = useMemo(() => {
const flat = prosthesisRows.flatMap((row) =>
splitProsthesisGroupCode(row.prosthesisTypeCode).flatMap((code) =>
row.teeth.map((tooth) => ({ tooth, prosthesisTypeCode: code })),
),
);
if (prosthesisCatalog?.length) {
return toothRegionColors(flat, prosthesisCatalog);
}
const crown: Partial<Record<FdiToothId, string>> = {};
prosthesisRows.forEach((row, index) => {
const color = prosthesisCatalog?.length
? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog)
: prosthesisTypeColor(row.prosthesisTypeCode, index);
const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
for (const tooth of row.teeth) {
colors[tooth as FdiToothId] = color;
crown[tooth as FdiToothId] = color;
}
});
return colors;
return { crown, root: {} as Partial<Record<FdiToothId, string>> };
}, [prosthesisRows, prosthesisCatalog]);
const resolvedConnected = useMemo(() => {
@@ -77,7 +84,8 @@ export function CaseToothChartPanel({
selected={selected}
readOnly
scale={scale}
toothColors={toothColors}
crownColors={regionColors.crown}
rootColors={regionColors.root}
connectedTeeth={resolvedConnected.size > 0 ? resolvedConnected : undefined}
compact={compact}
className={className}

View File

@@ -1,8 +1,10 @@
'use client';
import { useTranslations } from 'next-intl';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import {
formatToothList,
prosthesisGroupLabelFromCatalog,
prosthesisTypeColorFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -21,7 +23,7 @@ interface LabCaseProsthesisGroupsListProps {
}
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
return catalog.find((entry) => entry.code === code)?.label ?? code;
return prosthesisGroupLabelFromCatalog(code, catalog);
}
export function LabCaseProsthesisGroupsList({
@@ -29,6 +31,11 @@ export function LabCaseProsthesisGroupsList({
prosthesisCatalog,
fallbackTeeth = [],
}: LabCaseProsthesisGroupsListProps) {
const tTreatment = useTranslations('treatment');
const archLabels = {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
};
if (groups.length > 0) {
return (
<ul className="space-y-0.5">
@@ -46,7 +53,7 @@ export function LabCaseProsthesisGroupsList({
{group.teeth.length > 0 ? (
<span className="text-text-muted">
{' · '}
{formatToothList(group.teeth)}
{formatToothList(group.teeth, archLabels)}
</span>
) : null}
{group.connected ? (
@@ -59,8 +66,62 @@ export function LabCaseProsthesisGroupsList({
}
if (fallbackTeeth.length > 0) {
return <p className="text-[11px] text-text-muted">{formatToothList(fallbackTeeth)}</p>;
return <p className="text-[11px] text-text-muted">{formatToothList(fallbackTeeth, archLabels)}</p>;
}
return null;
}
export type LabCaseToothJobRow = {
tooth: string;
codes: string[];
connected?: boolean;
};
interface LabCaseToothJobsListProps {
rows: LabCaseToothJobRow[];
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
}
export function LabCaseToothJobsList({
rows,
prosthesisCatalog,
}: LabCaseToothJobsListProps) {
const tTreatment = useTranslations('treatment');
const archLabels = {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
};
if (rows.length === 0) return null;
return (
<ul className="space-y-1">
{rows.map((row) => (
<li
key={row.tooth}
className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[11px] leading-snug"
>
<span className="font-semibold text-text-primary">
{formatToothList([row.tooth], archLabels)}
</span>
{row.codes.map((code) => (
<span key={code} className="inline-flex items-baseline gap-x-1.5">
<span className="text-text-muted" aria-hidden>
·
</span>
<span
className="font-medium"
style={{ color: prosthesisTypeColorFromCatalog(code, prosthesisCatalog) }}
>
{prosthesisLabel(code, prosthesisCatalog)}
</span>
</span>
))}
{row.connected ? (
<ConnectedSelectionBadge className="align-middle text-[9px] px-1 py-px" />
) : null}
</li>
))}
</ul>
);
}

View File

@@ -16,6 +16,11 @@ export function TaskProsthesisGroupHeader({
prosthesisCatalog,
}: TaskProsthesisGroupHeaderProps) {
const t = useTranslations('tasks');
const tTreatment = useTranslations('treatment');
const archLabels = {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
};
return (
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
@@ -29,7 +34,7 @@ export function TaskProsthesisGroupHeader({
{group.prosthesisTypeLabel}
</Badge>
<span className="text-xs text-text-secondary">
{t('teethLabel', { teeth: formatToothList(group.teeth) })}
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}
</span>
</div>
);

View File

@@ -66,6 +66,11 @@ export function TaskRow({
onShowInCase,
}: TaskRowProps) {
const t = useTranslations('tasks');
const tTreatment = useTranslations('treatment');
const archLabels = {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
};
const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit);
const assignedToOther =
Boolean(task.assignee) && task.assignee!.id !== currentUserId;
@@ -169,7 +174,7 @@ export function TaskRow({
{flatMode ? (
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '}
· {t('teethLabel', { teeth: formatToothList(task.teeth) })}
· {t('teethLabel', { teeth: formatToothList(task.teeth, archLabels) })}
</p>
) : null}
<p className="text-[11px] text-text-muted truncate">

View File

@@ -1,100 +0,0 @@
'use client';
export type WizardStepItem = {
id: string;
label: string;
};
type WizardStepperProps = {
steps: WizardStepItem[];
currentStepId: string;
onStepChange: (stepId: string) => void;
/** Accessible name for the stepper. */
'aria-label': string;
className?: string;
};
/**
* Compact horizontal progress stepper — numbered nodes + connector rail.
* Distinct from chip/tab row patterns used elsewhere (e.g. treatment detail chips).
*/
export function WizardStepper({
steps,
currentStepId,
onStepChange,
'aria-label': ariaLabel,
className,
}: WizardStepperProps) {
const currentIndex = Math.max(
0,
steps.findIndex((step) => step.id === currentStepId),
);
return (
<nav aria-label={ariaLabel} className={className}>
<ol className="flex items-center gap-0 min-w-0">
{steps.map((step, index) => {
const isCurrent = index === currentIndex;
const isComplete = index < currentIndex;
const isUpcoming = index > currentIndex;
return (
<li key={step.id} className="flex items-center min-w-0 flex-1 last:flex-none">
<button
type="button"
aria-current={isCurrent ? 'step' : undefined}
onClick={() => onStepChange(step.id)}
className={`
group inline-flex items-center gap-1.5 shrink-0 rounded-[var(--radius-sm)]
py-0.5 pe-1 ps-0.5 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${isUpcoming ? 'opacity-70 hover:opacity-100' : ''}
`}
>
<span
className={`
inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full
text-[10px] font-semibold leading-none tabular-nums transition-colors
${
isCurrent
? 'bg-primary text-primary-contrast shadow-[0_0_0_2px_var(--color-primary-soft)]'
: isComplete
? 'bg-primary-soft text-primary'
: 'bg-background-secondary text-text-muted ring-1 ring-inset ring-border'
}
`}
>
{index + 1}
</span>
<span
className={`
text-[11px] leading-none whitespace-nowrap transition-colors
${
isCurrent
? 'font-semibold text-text-primary'
: isComplete
? 'font-medium text-text-secondary'
: 'font-medium text-text-muted'
}
`}
>
{step.label}
</span>
</button>
{index < steps.length - 1 ? (
<span
aria-hidden
className={`
mx-1.5 h-px min-w-[0.75rem] flex-1 rounded-full
${isComplete ? 'bg-primary/55' : 'bg-border'}
`}
/>
) : null}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
@@ -75,7 +75,7 @@ function ToothNumber({
style?: CSSProperties;
interactive: boolean;
disabled: boolean;
onActivate: (shiftKey: boolean) => void;
onActivate: (keys: { shiftKey: boolean; ctrlKey: boolean }) => void;
}) {
const className = `text-[10px] tabular-nums text-center leading-none ${colorClass}`;
const boxStyle: CSSProperties = { width: `${widthRem}rem`, ...style };
@@ -94,17 +94,17 @@ function ToothNumber({
disabled={disabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
onActivate(e.shiftKey);
onActivate({ shiftKey: e.shiftKey, ctrlKey: e.ctrlKey || e.metaKey });
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onActivate(e.shiftKey);
onActivate({ shiftKey: e.shiftKey, ctrlKey: e.ctrlKey || e.metaKey });
}}
aria-pressed={selected}
className={`${className} bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
@@ -128,6 +128,14 @@ interface FdiToothChartProps {
/** Filled connection edges (`toothEdgeKey(a,b)`). Preferred over `connectedTeeth` for edit mode. */
linkedEdges?: ReadonlySet<string>;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
/**
* Click-to-assign (Exocad): click opens the job picker; shift/drag paints.
* When set, this replaces `onToggle` for tooth hits.
*/
onAssignPointer?: (
fdi: FdiToothId,
event: { shiftKey: boolean; ctrlKey: boolean; paint: boolean },
) => void;
/** Toggle connect/disconnect for an adjacent selected pair. */
onToggleLink?: (a: FdiToothId, b: FdiToothId) => void;
disabled?: boolean;
@@ -137,8 +145,22 @@ interface FdiToothChartProps {
scale?: number;
/** Per-tooth accent color for selected glow (prosthesis / treatment-type palettes). */
toothColors?: Partial<Record<FdiToothId, string>>;
crownColors?: Partial<Record<FdiToothId, string>>;
rootColors?: Partial<Record<FdiToothId, string>>;
archHighlight?: 'upper' | 'lower' | 'both' | null;
/** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
headerControl?: ReactNode;
/** Centered overlay on the odontogram (prosthesis picker). */
overlay?: ReactNode;
/** Wipe all selected teeth (and attached jobs in the parent). */
onReset?: () => void;
resetDisabled?: boolean;
/** When set, overrides `selected.size > 0` for enabling Reset (e.g. arch jobs). */
hasResetWork?: boolean;
/** Overrides the Selected: line (arch labels, mixed arch + teeth). */
selectedSummary?: ReactNode;
/** Click Upper/Lower arch labels to assign jaw-level jobs. */
onArchClick?: (arch: 'upper' | 'lower') => void;
/** Compact card for embedded case detail panels. */
compact?: boolean;
/** Nested in the treatment editor — no extra card chrome. */
@@ -191,12 +213,22 @@ export function FdiToothChart({
connectedTeeth,
linkedEdges,
onToggle,
onAssignPointer,
onToggleLink,
disabled,
readOnly = false,
scale = 1,
toothColors,
crownColors,
rootColors,
archHighlight = null,
headerControl,
overlay,
onReset,
resetDisabled,
hasResetWork,
selectedSummary,
onArchClick,
compact = false,
embedded = false,
className = '',
@@ -205,11 +237,24 @@ export function FdiToothChart({
const tCommon = useTranslations('common');
const uid = useId().replace(/:/g, '');
const [hintOpen, setHintOpen] = useState(false);
const paintingRef = useRef(false);
const archPeak = 8;
const interactive = !readOnly && Boolean(onToggle);
const interactive = !readOnly && (Boolean(onToggle) || Boolean(onAssignPointer));
const linkInteractive = interactive && Boolean(onToggleLink);
const isDisabled = disabled || readOnly;
useEffect(() => {
const endPaint = () => {
paintingRef.current = false;
};
window.addEventListener('pointerup', endPaint);
window.addEventListener('pointercancel', endPaint);
return () => {
window.removeEventListener('pointerup', endPaint);
window.removeEventListener('pointercancel', endPaint);
};
}, []);
const toothSizeClass = (fdi: FdiToothId) => toothSize(fdi);
const archOffset = (index: number, count: number, upper?: boolean) => {
@@ -225,7 +270,8 @@ export function FdiToothChart({
return normalized * 4;
};
const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi];
const toothAccent = (fdi: FdiToothId) =>
crownColors?.[fdi] ?? rootColors?.[fdi] ?? toothColors?.[fdi];
const numberColorClass = (fdi: FdiToothId, isSel: boolean) => {
if (!isSel) return 'text-text-muted';
@@ -342,7 +388,7 @@ export function FdiToothChart({
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} min-w-max mx-auto w-fit`}>
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
const gid = `${uid}-${fdi}`;
const isSel = selected.has(fdi);
const size = toothSizeClass(fdi);
const tweak = TOOTH_TWEAKS[fdi];
@@ -366,15 +412,26 @@ export function FdiToothChart({
width: `${size.glyphWidthRem}rem`,
height: size.glyphHeightRem === 'auto' ? 'auto' : `${size.glyphHeightRem}rem`,
}}
accentColor={isSel ? accent : undefined}
accentColor={accent}
crownAccent={crownColors?.[fdi]}
rootAccent={rootColors?.[fdi]}
/>
);
/** Cover crown/root shift toward the numbers without overlapping neighbors. */
const hitOverflowPx = Math.abs(offsetY);
const activateTooth = (shiftKey: boolean) => {
const activateTooth = (keys: { shiftKey: boolean; ctrlKey?: boolean }, paint = false) => {
if (!interactive || isDisabled) return;
onToggle?.(fdi, { shiftKey });
const ctrlKey = Boolean(keys.ctrlKey);
if (onAssignPointer) {
onAssignPointer(fdi, {
shiftKey: keys.shiftKey,
ctrlKey,
paint: paint || ctrlKey,
});
return;
}
onToggle?.(fdi, { shiftKey: keys.shiftKey });
};
return (
@@ -400,18 +457,34 @@ export function FdiToothChart({
disabled={isDisabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
activateTooth(e.shiftKey);
if (onAssignPointer && !e.shiftKey) {
paintingRef.current = true;
}
activateTooth({
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
});
}}
onPointerEnter={() => {
if (!onAssignPointer || !paintingRef.current || isDisabled) return;
activateTooth({ shiftKey: false }, true);
}}
onPointerUp={() => {
paintingRef.current = false;
}}
onPointerCancel={() => {
paintingRef.current = false;
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
activateTooth(e.shiftKey);
activateTooth({ shiftKey: e.shiftKey, ctrlKey: e.ctrlKey || e.metaKey });
}}
className={`
absolute inset-y-[6%] z-10 rounded-[var(--radius-sm)] bg-transparent select-none touch-manipulation
@@ -467,16 +540,46 @@ export function FdiToothChart({
const chartBody = (
<>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
{onArchClick && !readOnly ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onArchClick('upper')}
className={`mx-auto mb-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
archHighlight === 'upper' || archHighlight === 'both'
? 'text-primary font-semibold'
: 'text-text-muted hover:text-text-primary'
}`}
>
{t('upperArch')}
</button>
) : (
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
)}
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none">
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none" dir="ltr">
<div className="relative isolate min-w-max mx-auto w-fit select-none">
<div
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
aria-hidden
/>
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div
className={
archHighlight === 'upper' || archHighlight === 'both'
? 'rounded-lg bg-primary/10 ring-1 ring-primary/25'
: undefined
}
onClick={
onArchClick && !readOnly && !isDisabled
? (e) => {
if (e.target === e.currentTarget) onArchClick('upper');
}
: undefined
}
>
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
</div>
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
<ToothNumber
@@ -488,7 +591,17 @@ export function FdiToothChart({
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
onActivate={(keys) => {
if (onAssignPointer) {
onAssignPointer(fdi, {
shiftKey: keys.shiftKey,
ctrlKey: keys.ctrlKey,
paint: keys.ctrlKey,
});
return;
}
onToggle?.(fdi, { shiftKey: keys.shiftKey });
}}
/>
))}
</div>
@@ -507,19 +620,59 @@ export function FdiToothChart({
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
/>
))}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
onActivate={(keys) => {
if (onAssignPointer) {
onAssignPointer(fdi, {
shiftKey: keys.shiftKey,
ctrlKey: keys.ctrlKey,
paint: keys.ctrlKey,
});
return;
}
onToggle?.(fdi, { shiftKey: keys.shiftKey });
}}
/>
))}
</div>
<div className={TOOTH_NUMBER_GAP}>
<div
className={
archHighlight === 'lower' || archHighlight === 'both'
? 'rounded-lg bg-primary/10 ring-1 ring-primary/25'
: undefined
}
onClick={
onArchClick && !readOnly && !isDisabled
? (e) => {
if (e.target === e.currentTarget) onArchClick('lower');
}
: undefined
}
>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
</div>
</div>
</div>
</div>
</div>
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
{onArchClick && !readOnly ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onArchClick('lower')}
className={`mx-auto mt-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
archHighlight === 'lower' || archHighlight === 'both'
? 'text-primary font-semibold'
: 'text-text-muted hover:text-text-primary'
}`}
>
{t('lowerArch')}
</button>
) : (
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
)}
</>
);
@@ -542,18 +695,36 @@ export function FdiToothChart({
) : null}
</div>
<div className="flex justify-center">{headerControl}</div>
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
<div className="flex min-w-0 flex-col items-end gap-1">
{onReset && !readOnly ? (
<button
type="button"
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
onClick={onReset}
aria-label={t('resetChartAria')}
className="rounded-[var(--radius-md)] border border-border/70 px-2 py-0.5 text-[11px] text-text-secondary hover:border-border hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 disabled:cursor-not-allowed disabled:opacity-40"
>
{t('resetChart')}
</button>
) : null}
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
{t('selectedLabel')}{' '}
{selectedSummary ??
(selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', '))}
</p>
</div>
</div>
{scale !== 1 ? (
<div style={{ zoom: scale }} className="mx-auto w-fit origin-top">
<div className="relative mx-auto w-fit origin-top" style={{ zoom: scale }}>
{chartBody}
{overlay}
</div>
) : (
chartBody
<div className="relative">
{chartBody}
{overlay}
</div>
)}
{hintOpen ? (
@@ -573,7 +744,9 @@ export function FdiToothChart({
</h4>
<DialogCloseButton onClick={() => setHintOpen(false)} />
</div>
<p className="text-sm text-text-secondary leading-relaxed">{t('toothChartHint')}</p>
<p className="text-sm text-text-secondary leading-relaxed">
{onAssignPointer ? t('toothChartHintAssign') : t('toothChartHint')}
</p>
<div className="mt-4">
<Button type="button" variant="outline" fullWidth onClick={() => setHintOpen(false)}>
{tCommon('close')}

View File

@@ -4,11 +4,9 @@ import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection';
@@ -20,7 +18,12 @@ import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/trea
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import {
isArchSentinel,
toothJobRowsForDetail,
} from '@/components/treatment/prosthesisTree';
import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
@@ -28,6 +31,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog?: ProsthesisCatalogEntry[];
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -48,66 +52,27 @@ interface LabCasesDispatchPanelProps {
onCommentError?: (message: string) => void;
}
type ProsthesisGroupRow = {
groupId: string;
kind: 'connected' | 'single';
teeth: string[];
detailClientId: string;
detailNumber: number;
};
function prosthesisGroupRows(
labCase: LabCaseDraft,
activeDetail: TreatmentDetailDraft,
detailNumber: number,
): ProsthesisGroupRow[] {
if (labCase.detailClientId !== activeDetail.clientId) return [];
function prosthesisTeethForDetail(activeDetail: TreatmentDetailDraft): string[] {
if (activeDetail.treatmentType !== 'prosthesis') return [];
const groups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.teeth);
return groups.map((g) => ({
groupId: g.groupId,
kind: g.kind,
teeth: g.teeth,
detailClientId: activeDetail.clientId,
detailNumber,
}));
if (activeDetail.toothSelectionGroups.length > 0) {
return activeDetail.toothSelectionGroups.flatMap((g) => g.teeth);
}
return activeDetail.teeth;
}
function isProsthesisMapComplete(
labCase: LabCaseDraft,
rows: ProsthesisGroupRow[],
fdiTeeth: string[],
detailClientId: string,
): boolean {
if (rows.length === 0) return true;
return rows.every((row) =>
row.teeth.every((tooth) =>
labCase.toothProsthesis.some(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === tooth &&
tp.selectionGroupId === row.groupId &&
Boolean(tp.prosthesisTypeCode),
),
),
);
}
function toothProsthesisForRows(
rows: ProsthesisGroupRow[],
prosthesisTypeCode: string,
): LabCaseDraft['toothProsthesis'] {
return rows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
const assigned = new Set(
labCase.toothProsthesis
.filter((tp) => tp.detailClientId === detailClientId && Boolean(tp.prosthesisTypeCode))
.map((tp) => tp.tooth),
);
const hasArch = [...assigned].some(isArchSentinel);
if (fdiTeeth.length === 0) return hasArch;
return fdiTeeth.every((tooth) => assigned.has(tooth));
}
export function LabCasesDispatchPanel({
@@ -116,6 +81,7 @@ export function LabCasesDispatchPanel({
labCases,
labDependentCodes,
treatmentCatalog,
prosthesisCatalog = [],
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -137,8 +103,7 @@ export function LabCasesDispatchPanel({
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [fetchedCatalog, setFetchedCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const [pendingComment, setPendingComment] = useState('');
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
@@ -149,7 +114,7 @@ export function LabCasesDispatchPanel({
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
const isLabDependentDetail = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
);
const labCaseForActiveDetail =
@@ -167,44 +132,35 @@ export function LabCasesDispatchPanel({
null;
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
const activeLabOrgName = activeLabCase?.destinationOrganizationId
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null;
const prosthesisRows = activeLabCase && activeDetail
? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber)
: [];
const fdiTeeth = activeDetail ? prosthesisTeethForDetail(activeDetail) : [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
: true;
const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0);
? isProsthesisMapComplete(activeLabCase, fdiTeeth, activeDetailId)
: false;
const catalog = prosthesisCatalog.length > 0 ? prosthesisCatalog : fetchedCatalog;
useEffect(() => {
if (!activeLabCase?.destinationOrganizationId) {
setProsthesisOptions([]);
return;
}
if (prosthesisCatalog.length > 0) return;
let cancelled = false;
void prosthesisCatalogApi
.list(activeLabCase.destinationOrganizationId)
.list()
.then((res) => {
if (!cancelled) setProsthesisOptions(res.data);
if (!cancelled) setFetchedCatalog(res.data);
})
.catch(() => {
if (!cancelled) setProsthesisOptions([]);
if (!cancelled) setFetchedCatalog([]);
});
return () => {
cancelled = true;
};
}, [activeLabCase?.destinationOrganizationId]);
}, [prosthesisCatalog.length]);
useEffect(() => {
setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -227,31 +183,6 @@ export function LabCasesDispatchPanel({
return null;
}
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
if (!activeLabCase) return;
const toothSet = new Set(row.teeth);
const rest = activeLabCase.toothProsthesis.filter(
(tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)),
);
const next = prosthesisTypeCode
? [
...rest,
...row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
if (!activeLabCase || sent) return;
const set = new Set(activeLabCase.attachmentIds);
@@ -263,9 +194,7 @@ export function LabCasesDispatchPanel({
function handleSelectOrganization(org: LinkedOrganizationOption) {
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -325,7 +254,14 @@ export function LabCasesDispatchPanel({
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
const connectedGroupIds = new Set(
(activeDetail.toothSelectionGroups ?? [])
.filter((group) => group.kind === 'connected')
.map((group) => group.groupId),
);
const toothJobRows = activeLabCase
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId, connectedGroupIds)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -333,9 +269,7 @@ export function LabCasesDispatchPanel({
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{typeLabel} · {teethLabel}
</p>
<p className="text-xs text-text-muted mt-0.5">{typeLabel}</p>
</div>
{activeLabCase ? renderDueDateField() : null}
</div>
@@ -363,6 +297,13 @@ export function LabCasesDispatchPanel({
orgs={orgs}
/>
{toothJobRows.length > 0 ? (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
@@ -415,89 +356,20 @@ export function LabCasesDispatchPanel({
)}
</div>
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
<div className="space-y-3 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
{flatToothCount > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
) : null}
<div className="space-y-3">
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.selectionGroupId === row.groupId &&
row.teeth.includes(tp.tooth),
)?.prosthesisTypeCode ??
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
row.teeth.includes(tp.tooth),
)?.prosthesisTypeCode ??
'';
return (
<label
key={row.groupId}
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2 min-w-0"
>
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
<span className="flex flex-wrap items-center gap-2 text-text-secondary min-w-0 break-words">
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
<span className="min-w-0">
{row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColTooth')}
{': '}
<span className="text-text-primary">{row.teeth.join(', ')}</span>
</span>
</span>
<select
value={current}
disabled={disabled}
onChange={(e) => setGroupProsthesis(row, e.target.value)}
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
aria-label={
row.kind === 'connected'
? t('prosthesisConnectedLabel')
: t('prosthesisColType')
}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</span>
</label>
);
})}
</div>
</div>
) : null}
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
<p className="text-[11px] text-text-muted">{t('prosthesisEditOnChart')}</p>
{toothJobRows.length === 0 ? (
<p className="text-xs text-amber-700">{t('prosthesisMissingOnChart')}</p>
) : (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
)}
</div>
{activeDetailAttachments.length > 0 ? (
<div>

View File

@@ -0,0 +1,349 @@
'use client';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import {
ProsthesisJobPopover,
type PickerStep,
} from '@/components/ui/treatment/ProsthesisJobPopover';
import {
applyLeafToJobs,
archSentinels,
archTargetFromJobs,
catalogByCode,
clearJobsForTeeth,
codesOnTooth,
ensureTeethInGroups,
hasArchJobs,
isArchSentinel,
jobsAllowImplantAddon,
jobsAllowPostCoreAddon,
removeTeethFromGroups,
toothRegionColors,
uniqueCodesOnGroup,
writeArchJobs,
writeJobsForTeeth,
type ArchTarget,
type PickerScope,
} from '@/components/treatment/prosthesisTree';
import {
applyShiftRange,
deriveTeethFromGroups,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
shiftRangeTeeth,
} from '@/components/treatment/toothSelectionGroups';
interface ProsthesisAssignChartProps {
groups: ToothSelectionGroup[];
toothProsthesis: LabCaseToothProsthesisDraft[];
detailClientId: string;
catalog: readonly ProsthesisCatalogEntry[];
disabled?: boolean;
className?: string;
headerControl?: ReactNode;
onToggleLink?: (a: FdiToothId, b: FdiToothId) => void;
onChange: (next: {
groups: ToothSelectionGroup[];
toothProsthesis: LabCaseToothProsthesisDraft[];
}) => void;
}
export function ProsthesisAssignChart({
groups,
toothProsthesis,
detailClientId,
catalog,
disabled,
className,
headerControl,
onToggleLink,
onChange,
}: ProsthesisAssignChartProps) {
const tTreatment = useTranslations('treatment');
const tProsthesis = useTranslations('prosthesis');
const [open, setOpen] = useState(false);
const [mobile, setMobile] = useState(false);
const [anchor, setAnchor] = useState<FdiToothId | null>(null);
const [step, setStep] = useState<PickerStep>({ kind: 'category' });
const [scope, setScope] = useState<PickerScope>('tooth');
const [lockedArch, setLockedArch] = useState<ArchTarget>('upper');
const brushRef = useRef<string[]>([]);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const pendingRangeRef = useRef<FdiToothId[] | null>(null);
const [pendingRange, setPendingRange] = useState<FdiToothId[] | null>(null);
const byCode = useMemo(() => catalogByCode(catalog), [catalog]);
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
const sync = () => setMobile(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
const selected = useMemo(() => new Set(deriveTeethFromGroups(groups)), [groups]);
const linkedEdges = useMemo(() => linkedEdgesFromGroups(groups), [groups]);
const detailRows = useMemo(
() => toothProsthesis.filter((r) => r.detailClientId === detailClientId),
[toothProsthesis, detailClientId],
);
const regionColors = useMemo(
() => toothRegionColors(detailRows, catalog),
[detailRows, catalog],
);
const persistedArch = useMemo(
() => archTargetFromJobs(toothProsthesis, detailClientId),
[toothProsthesis, detailClientId],
);
const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch;
const archWork = hasArchJobs(toothProsthesis, detailClientId);
const focusTooth = anchor ?? [...selected][0] ?? null;
const jobCodes =
scope === 'arch'
? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch))
: focusTooth
? codesOnTooth(toothProsthesis, detailClientId, focusTooth)
: [];
function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string {
return nextGroups.find((g) => g.teeth.includes(tooth as FdiToothId))?.groupId ?? '';
}
function commit(nextGroups: ToothSelectionGroup[], nextRows: LabCaseToothProsthesisDraft[]) {
onChange({ groups: nextGroups, toothProsthesis: nextRows });
}
function applyCodes(teeth: FdiToothId[], codes: string[]) {
const nextGroups = ensureTeethInGroups(groups, teeth);
const nextRows = writeJobsForTeeth(
toothProsthesis,
detailClientId,
teeth,
codes,
(tooth) => groupIdForTooth(tooth, nextGroups),
);
brushRef.current = codes;
commit(nextGroups, nextRows);
}
function setRange(next: FdiToothId[] | null) {
pendingRangeRef.current = next;
setPendingRange(next);
}
function openToothPicker(fdi: FdiToothId) {
const pending = pendingRangeRef.current;
if (pending && !pending.includes(fdi)) {
setRange(null);
}
setScope('tooth');
setAnchor(fdi);
setStep({ kind: 'category' });
rangeAnchorRef.current = fdi;
setOpen(true);
}
function handleAssignPointer(
fdi: FdiToothId,
event: { shiftKey: boolean; ctrlKey: boolean; paint: boolean },
) {
if (disabled) return;
if (event.paint || event.ctrlKey) {
if (brushRef.current.length === 0) return;
setRange(null);
applyCodes([fdi], brushRef.current);
return;
}
if (event.shiftKey) {
const start = rangeAnchorRef.current;
if (!start || start === fdi) {
rangeAnchorRef.current = fdi;
return;
}
const nextGroups = applyShiftRange(groups, start, fdi);
const union = shiftRangeTeeth(groups, start, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups || !union) return;
setRange(union);
commit(nextGroups, toothProsthesis);
return;
}
openToothPicker(fdi);
}
function handleArchClick(arch: 'upper' | 'lower') {
if (disabled) return;
setRange(null);
setScope('arch');
setLockedArch(arch);
setStep({ kind: 'category' });
setOpen(true);
}
function handlePickLeaf(code: string, arch?: ArchTarget) {
if (scope === 'arch' || arch) {
const target = arch ?? lockedArch;
commit(groups, writeArchJobs(toothProsthesis, detailClientId, target, code));
brushRef.current = [code];
setStep({ kind: 'category' });
return;
}
const pending = pendingRangeRef.current;
const targets =
pending && pending.length > 0 && anchor && pending.includes(anchor)
? pending
: anchor
? [anchor]
: [];
if (targets.length === 0) return;
const base = codesOnTooth(toothProsthesis, detailClientId, targets[0]);
const nextCodes = applyLeafToJobs(base, code, byCode);
applyCodes(targets, nextCodes);
setRange(null);
setStep({ kind: 'category' });
}
function handlePickAddon(code: string | null, kind: 'implant' | 'post_core') {
const tooth = focusTooth;
if (!tooth || scope === 'arch') return;
const current = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => {
const entry = byCode.get(c);
return entry?.addonKind !== kind;
});
const next = code ? applyLeafToJobs(current, code, byCode) : current;
applyCodes([tooth], next);
setStep({ kind: 'category' });
}
function handleRemoveJob(code: string) {
if (scope === 'arch') {
const rest = jobCodes.filter((c) => c !== code);
if (rest.length === 0) {
commit(
groups,
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
);
return;
}
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0]));
return;
}
const tooth = focusTooth;
if (!tooth) return;
const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code);
if (next.length === 0) {
commit(
removeTeethFromGroups(groups, [tooth]),
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
);
brushRef.current = [];
return;
}
applyCodes([tooth], next);
}
function handleClear() {
if (scope === 'arch') {
commit(
groups,
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
);
setOpen(false);
return;
}
const tooth = focusTooth;
if (!tooth) return;
commit(
removeTeethFromGroups(groups, [tooth]),
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
);
brushRef.current = [];
setOpen(false);
setAnchor(null);
setRange(null);
}
function handleReset() {
if (disabled || (selected.size === 0 && !archWork)) return;
if (!window.confirm(tTreatment('confirmResetChart'))) return;
const fdiTeeth = [...selected];
const withoutFdi = clearJobsForTeeth(toothProsthesis, detailClientId, fdiTeeth);
const nextRows = withoutFdi.filter(
(row) => !(row.detailClientId === detailClientId && isArchSentinel(row.tooth)),
);
commit(groupsFromFlatTeeth([]), nextRows);
brushRef.current = [];
setOpen(false);
setAnchor(null);
setRange(null);
}
const selectedSummary = (() => {
const parts: string[] = [];
if (persistedArch === 'upper' || persistedArch === 'both') {
parts.push(tTreatment('selectedArchUpper'));
}
if (persistedArch === 'lower' || persistedArch === 'both') {
parts.push(tTreatment('selectedArchLower'));
}
if (selected.size > 0) {
parts.push([...selected].sort().join(', '));
}
return parts.length ? parts.join(' · ') : tTreatment('selectedEmpty');
})();
return (
<div>
<FdiToothChart
className={className}
selected={selected}
linkedEdges={linkedEdges}
disabled={disabled}
crownColors={regionColors.crown}
rootColors={regionColors.root}
archHighlight={archHighlight}
headerControl={headerControl}
selectedSummary={selectedSummary}
hasResetWork={selected.size > 0 || archWork}
onArchClick={handleArchClick}
overlay={
<ProsthesisJobPopover
open={open}
mobile={mobile}
catalog={catalog}
step={step}
scope={scope}
lockedArch={scope === 'arch' ? lockedArch : undefined}
jobCodes={jobCodes}
tooth={focusTooth}
rangeLabel={
pendingRange && pendingRange.length > 1 ? pendingRange.join(', ') : null
}
allowImplantAddon={
scope === 'tooth' && jobsAllowImplantAddon(jobCodes, byCode)
}
allowPostCoreAddon={
scope === 'tooth' && jobsAllowPostCoreAddon(jobCodes, byCode)
}
onStep={setStep}
onLockedArchChange={setLockedArch}
onPickLeaf={handlePickLeaf}
onPickAddon={handlePickAddon}
onRemoveJob={handleRemoveJob}
onClear={handleClear}
onClose={() => setOpen(false)}
/>
}
onAssignPointer={handleAssignPointer}
onToggleLink={onToggleLink}
onReset={handleReset}
resetDisabled={disabled}
/>
</div>
);
}

View File

@@ -0,0 +1,477 @@
'use client';
import type { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { X } 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 { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import {
addonLeaves,
categoryCodesInCatalog,
categoryTileColor,
leavesFor,
subcategoriesFor,
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';
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';
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' };
interface ProsthesisJobPopoverProps {
open: boolean;
mobile: boolean;
catalog: readonly ProsthesisCatalogEntry[];
step: PickerStep;
scope: PickerScope;
lockedArch?: ArchTarget;
jobCodes: string[];
tooth: string | null;
rangeLabel?: string | null;
allowImplantAddon: boolean;
allowPostCoreAddon: boolean;
onStep: (step: PickerStep) => void;
onLockedArchChange?: (arch: ArchTarget) => void;
onPickLeaf: (code: string, arch?: ArchTarget) => void;
onPickAddon: (code: string | null, kind: 'implant' | 'post_core') => void;
onRemoveJob: (code: string) => void;
onClear: () => void;
onClose: () => void;
}
function Tile({
label,
caption,
color,
active,
onClick,
}: {
label: string;
caption?: string;
color?: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={caption ? `${caption}: ${label}` : 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'
}`}
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' };
}
if (step.kind === 'subcategory') return { kind: 'category' };
return { kind: 'category' };
}
export function ProsthesisJobPopover({
open,
mobile,
catalog,
step,
scope,
lockedArch,
jobCodes,
tooth,
rangeLabel,
allowImplantAddon,
allowPostCoreAddon,
onStep,
onLockedArchChange,
onPickLeaf,
onPickAddon,
onRemoveJob,
onClear,
onClose,
}: ProsthesisJobPopoverProps) {
const t = useTranslations('prosthesis');
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 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;
function categoryLabel(code: string) {
return t(`category_${code}` as never);
}
function subLabel(code: string) {
return t(`sub_${code}` as never);
}
const back = parentStep(step);
function openCategory(code: string) {
const subs = subcategoriesFor(catalog, code);
const arch = scope === 'arch' ? lockedArch : undefined;
if (subs.length > 0) {
onStep({ kind: 'subcategory', category: code, arch });
return;
}
onStep({ kind: 'leaves', category: code, arch });
}
function leafTile(leaf: ProsthesisCatalogEntry) {
return (
<Tile
key={leaf.code}
label={leaf.label}
color={prosthesisTypeColorFromCatalog(leaf.code, catalog)}
active={jobCodes.includes(leaf.code)}
onClick={() => onPickLeaf(leaf.code, archForLeaves)}
/>
);
}
const addonTiles: ReactNode[] = [];
if (scope === 'tooth' && hasLeaf) {
if (allowImplantAddon && implantAddons.length > 0) {
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' },
)
}
/>,
);
}
if (allowPostCoreAddon && postCoreAddons.length > 0) {
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' },
)
}
/>,
);
}
}
let selectedNode: { label: string; color?: string } | null = null;
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) => (
<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).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).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,
).map(leafTile);
childTiles = [...subs, ...leaves];
}
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
)}
</div>
);
const title = rangeLabel
? t('pickerTeeth', { teeth: rangeLabel })
: scope === 'arch'
? lockedArch === 'both'
? t('pickerArchBoth')
: lockedArch === 'lower'
? t('pickerArchLower')
: t('pickerArchUpper')
: tooth
? 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">
<Button
type="button"
variant="outline"
size="sm"
className={HEADER_BTN_CLASS}
disabled={!hasLeaf}
onClick={onClear}
>
{scope === 'arch' ? t('clearArch') : t('clear')}
</Button>
<Button
type="button"
variant="primary"
size="sm"
className={HEADER_BTN_CLASS}
onClick={onClose}
>
{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);
return (
<span
key={code}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium"
style={{
backgroundColor: color,
color: CHIP_INK,
borderColor: 'rgba(0,0,0,0.16)',
}}
>
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
</span>
{entry?.label ?? code}
<button
type="button"
className="rounded-full p-0.5 hover:bg-black/10"
aria-label={t('removeJob')}
onClick={(e) => {
e.stopPropagation();
onRemoveJob(code);
}}
>
<X className="h-3 w-3" 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>
</div>
);
if (mobile) {
return (
<ResponsiveDialogOverlay onBackdropClick={onClose}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
maxWidthClass="sm:max-w-lg"
className="max-h-[90dvh] flex flex-col"
>
<h4 className="sr-only">{t('pickerTitle')}</h4>
<div className="min-h-0 flex-1 overflow-hidden">{body}</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
);
}
return (
<div className="absolute inset-0 z-30 pointer-events-none">
<button
type="button"
className="absolute inset-0 bg-background-primary/55 pointer-events-auto"
aria-label={t('done')}
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"
role="dialog"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<h4 className="sr-only">{t('pickerTitle')}</h4>
{body}
</div>
</div>
);
}

View File

@@ -21,6 +21,10 @@ interface ToothGlyphProps {
style?: CSSProperties;
/** When set, selected tooth glow + fill use this color instead of primary. */
accentColor?: string;
/** Independent crown tint (stacked implant + restoration). */
crownAccent?: string;
/** Independent root tint. */
rootAccent?: string;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
@@ -132,12 +136,17 @@ export const ToothGlyph = memo(function ToothGlyph({
upsideDown,
className = 'w-8 h-[4.85rem]',
accentColor,
crownAccent,
rootAccent,
style,
}: ToothGlyphProps) {
const realistic = getRealisticToothAsset(fdi);
const filter = selected
? accentColor
? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})`
const regionSelected = Boolean(crownAccent || rootAccent);
const isOn = selected || regionSelected;
const glow = crownAccent ?? rootAccent ?? accentColor;
const filter = isOn
? glow
? `drop-shadow(0 0 6px ${rgbaFromHex(glow, 0.75)})`
: 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))'
: 'drop-shadow(0 1px 1.5px rgba(15, 23, 42, 0.18))';
const svgStyle: CSSProperties = { filter, ...style };
@@ -145,32 +154,34 @@ export const ToothGlyph = memo(function ToothGlyph({
if (realistic) {
const crownGradId = `${gradientId}-crown`;
const rootGradId = `${gradientId}-root`;
// Align crown edge toward the FDI numbers (upper: bottom of box, lower: top).
const preserveAspectRatio = upper ? 'xMidYMax meet' : 'xMidYMin meet';
const selectedDefault = selected && !crownAccent && !rootAccent;
const crownColor = crownAccent ?? (selectedDefault ? accentColor : undefined);
const rootColor = rootAccent ?? (selectedDefault ? accentColor : undefined);
const crownStops = selected
? accentColor
? { hi: '#ffffff', mid: accentColor, shade: mixHex(accentColor, '#0f172a', 0.22) }
: { hi: '#ffffff', mid: '#5eead4', shade: '#0e7490' }
: {
hi: 'var(--tooth-crown-hi)',
mid: 'var(--tooth-crown-mid)',
shade: 'var(--tooth-crown-shade)',
};
const crownStops = crownColor
? { hi: '#ffffff', mid: crownColor, shade: mixHex(crownColor, '#0f172a', 0.22) }
: selectedDefault
? { hi: '#ffffff', mid: '#5eead4', shade: '#0e7490' }
: {
hi: 'var(--tooth-crown-hi)',
mid: 'var(--tooth-crown-mid)',
shade: 'var(--tooth-crown-shade)',
};
const rootStops = selected
? accentColor
? {
hi: mixHex(accentColor, '#ffffff', 0.2),
mid: mixHex(accentColor, '#0f172a', 0.12),
shade: mixHex(accentColor, '#0f172a', 0.32),
}
: { hi: '#99f6e4', mid: '#14b8a6', shade: '#0f766e' }
: {
hi: 'var(--tooth-root-hi)',
mid: 'var(--tooth-root-mid)',
shade: 'var(--tooth-root-shade)',
};
const rootStops = rootColor
? {
hi: mixHex(rootColor, '#ffffff', 0.2),
mid: mixHex(rootColor, '#0f172a', 0.12),
shade: mixHex(rootColor, '#0f172a', 0.32),
}
: selectedDefault
? { hi: '#99f6e4', mid: '#14b8a6', shade: '#0f766e' }
: {
hi: 'var(--tooth-root-hi)',
mid: 'var(--tooth-root-mid)',
shade: 'var(--tooth-root-shade)',
};
return (
<svg
@@ -193,7 +204,7 @@ export const ToothGlyph = memo(function ToothGlyph({
</linearGradient>
</defs>
<g transform={realistic.groupTransform}>
{renderRealisticFill(realistic, selected, crownGradId, rootGradId, accentColor)}
{renderRealisticFill(realistic, isOn, crownGradId, rootGradId, glow)}
</g>
</svg>
);
@@ -211,13 +222,13 @@ export const ToothGlyph = memo(function ToothGlyph({
);
}
const body = renderClinicalFill(model, selected, gradientId, accentColor);
const body = renderClinicalFill(model, isOn, gradientId, glow);
const selectedStops = accentColor
const selectedStops = glow
? {
inner: '#ffffff',
mid: accentColor,
outer: accentColor,
mid: glow,
outer: glow,
}
: {
inner: '#cffafe',
@@ -234,9 +245,9 @@ export const ToothGlyph = memo(function ToothGlyph({
>
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop offset="0%" stopColor={selected ? selectedStops.inner : '#ffffff'} />
<stop offset="55%" stopColor={selected ? selectedStops.mid : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? selectedStops.outer : '#93c5fd'} />
<stop offset="0%" stopColor={isOn ? selectedStops.inner : '#ffffff'} />
<stop offset="55%" stopColor={isOn ? selectedStops.mid : '#e0f2fe'} />
<stop offset="100%" stopColor={isOn ? selectedStops.outer : '#93c5fd'} />
</radialGradient>
</defs>
<g

View File

@@ -40,10 +40,6 @@ interface TreatmentDetailsEditorProps {
showFields?: boolean;
/** FDI chart (or other) rendered beside notes on wide screens. */
chart?: ReactNode;
/** Shown below chrome (e.g. prosthesis wizard). */
stepper?: ReactNode;
/** Shown below type + chart + notes (e.g. Continue to lab). */
footer?: ReactNode;
/**
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
* before this feature existed. Presence *is* the availability flag, so the two cannot
@@ -53,6 +49,10 @@ interface TreatmentDetailsEditorProps {
/** Dim the chart until a treatment type is chosen. */
chartLocked?: boolean;
chartLockMessage?: string;
/** Owned by TreatmentWorkspace so lab drafts can be wiped when leaving prosthesis. */
onTreatmentTypeChange?: (nextType: string) => void;
/** Arch-level jobs count as work for the missing-teeth lab banner. */
hasArchJobs?: boolean;
}
export function TreatmentDetailsEditor({
@@ -75,10 +75,10 @@ export function TreatmentDetailsEditor({
showFields = true,
voice,
chart,
stepper,
footer,
chartLocked = false,
chartLockMessage,
onTreatmentTypeChange,
hasArchJobs = false,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
@@ -89,10 +89,10 @@ export function TreatmentDetailsEditor({
const readOnly = !activeDetail || disabled || locked;
const showMissingTeethLabBlock = Boolean(
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes, hasArchJobs),
);
if (!showChrome && !showFields && !stepper) return null;
if (!showChrome && !showFields) return null;
const treatmentTypeTextColor =
activeDetail && isDetailTypeSelected(activeDetail)
@@ -223,8 +223,6 @@ export function TreatmentDetailsEditor({
</>
) : null}
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
@@ -235,7 +233,9 @@ export function TreatmentDetailsEditor({
<Dropdown
label={t('treatmentType')}
value={activeDetail.treatmentType}
onChange={(e) => setActiveType(e.target.value)}
onChange={(e) =>
(onTreatmentTypeChange ?? setActiveType)(e.target.value)
}
disabled={readOnly}
style={{ color: treatmentTypeTextColor }}
>
@@ -287,8 +287,6 @@ export function TreatmentDetailsEditor({
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : showFields && !activeDetail ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import {
TreatmentLabCasesPanel,
@@ -14,6 +13,7 @@ import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSec
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ProsthesisAssignChart } from '@/components/ui/treatment/ProsthesisAssignChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
@@ -44,10 +44,8 @@ import { unscheduledStripColorCode, type DayStripItem } from '@/components/treat
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
areUnscheduledDetailsStripDeletable,
} from '@/components/treatment/treatmentDetailRules';
import {
@@ -62,6 +60,7 @@ import {
toggleToothInGroups,
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs } from '@/components/treatment/prosthesisTree';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -97,7 +96,6 @@ import type {
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
type WorkspaceMode = 'live' | 'historical';
type EntryStep = 'treatment' | 'lab';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
@@ -107,6 +105,15 @@ function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
}
/** Prefer the unsent draft this chart writes to; fall back to a sent case for locked details. */
function labCaseDraftForDetail(
drafts: LabCaseDraft[],
detailClientId: string,
): LabCaseDraft | undefined {
const rows = drafts.filter((lc) => lc.detailClientId === detailClientId);
return rows.find((lc) => !lc.sentAt) ?? rows[0];
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
@@ -132,6 +139,7 @@ function labCaseDraftsToPast(
}
: null,
sends: lc.sends ?? [],
toothProsthesis: lc.toothProsthesis,
};
});
}
@@ -450,8 +458,8 @@ export function TreatmentWorkspace({
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
/** When set, switching the active detail scrolls to the lab dispatch panel. */
const pendingScrollToLabRef = useRef(false);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -470,7 +478,6 @@ export function TreatmentWorkspace({
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
@@ -629,27 +636,17 @@ export function TreatmentWorkspace({
[details, activeDetailId],
);
const showLabDispatchPanel = useMemo(
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
[details, labDependentCodes],
);
/** Lab send sheet only for prosthesis (lab-dependent) types on the active detail. */
const showLabWizardStep = useMemo(
const showLabDispatch = useMemo(
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
[activeDetail, labDependentCodes],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
const activeHasArchJobs = hasArchJobs(
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [],
activeDetailId,
);
useEffect(() => {
if (!selectedStandaloneId || draftHydratingRef.current) return;
@@ -853,28 +850,27 @@ export function TreatmentWorkspace({
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
const prosthesisAssignActive =
!showWholeTreatmentPlan &&
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)) &&
prosthesisCatalog.length > 0;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
useEffect(() => {
if (entryStep === 'lab' && !showLabWizardStep) {
setEntryStep('treatment');
if (pendingScrollToLabRef.current) {
pendingScrollToLabRef.current = false;
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
}
}, [entryStep, showLabWizardStep]);
}, [activeDetailId]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
@@ -1569,14 +1565,14 @@ export function TreatmentWorkspace({
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
}
hydrateFromTreatment(treatmentToLoad);
draftHydratingRef.current = false;
if (focusDetailClientId) {
if (options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
}
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
@@ -1589,7 +1585,6 @@ export function TreatmentWorkspace({
setActiveLabCaseId(linked.clientId);
}
if (options?.scrollToLabPanel !== false) {
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -1690,7 +1685,7 @@ export function TreatmentWorkspace({
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
@@ -1698,7 +1693,6 @@ export function TreatmentWorkspace({
if (linked) {
setActiveLabCaseId(linked.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -1759,13 +1753,12 @@ export function TreatmentWorkspace({
workspaceMode === 'live' &&
!isBrowsing
) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -2003,7 +1996,6 @@ export function TreatmentWorkspace({
// persistDraft reads detailsRef, and setDetails has not rendered yet.
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
@@ -2098,7 +2090,6 @@ export function TreatmentWorkspace({
detailsRef.current = nextDetails;
setDetails(nextDetails);
setActiveDetailId(nextActive);
if (nextDetails.length === 0) setEntryStep('treatment');
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
handleLabCasesChange(
@@ -2115,7 +2106,61 @@ export function TreatmentWorkspace({
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
setEntryStep,
t,
],
);
const handleTreatmentTypeChange = useCallback(
(nextType: string) => {
if (!canEditTreatmentForDay) return;
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail || isDetailLocked(detail)) return;
if (detail.treatmentType === nextType) return;
const leavingLab = labDependentCodes.has(detail.treatmentType);
const jobs =
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
const hasWork =
detail.teeth.length > 0 ||
detail.toothSelectionGroups.some((g) => g.teeth.length > 0) ||
jobs.length > 0;
if (leavingLab && hasWork && !window.confirm(t('confirmLeaveProsthesis'))) {
return;
}
const nextDetails = details.map((d) => {
if (d.clientId !== activeDetailId) return d;
if (leavingLab) {
return {
...d,
treatmentType: nextType,
teeth: [],
toothSelectionGroups: [],
};
}
return { ...d, treatmentType: nextType };
});
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (leavingLab) {
const nextDrafts = labCaseDrafts.filter(
(lc) => lc.detailClientId !== activeDetailId || Boolean(lc.sentAt),
);
if (nextDrafts.length !== labCaseDrafts.length) {
handleLabCasesChange(nextDrafts);
}
}
},
[
activeDetailId,
canEditTreatmentForDay,
details,
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
t,
],
);
@@ -2133,16 +2178,8 @@ export function TreatmentWorkspace({
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
@@ -2191,13 +2228,12 @@ export function TreatmentWorkspace({
tErrors,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
// Auto-create an unsent draft when the active detail is prosthesis (no wizard step).
useEffect(() => {
if (entryStep !== 'lab') return;
if (!showLabDispatch) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
if (!activeDetail || !labDependentCodes.has(activeDetail.treatmentType)) return;
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
if (isDetailLocked(activeDetail)) return;
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
@@ -2208,12 +2244,12 @@ export function TreatmentWorkspace({
activeDetailId,
canEditTreatmentForDay,
details,
entryStep,
handleAddLabCase,
hasLiveContext,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
showLabDispatch,
]);
const handleSendLabCase = useCallback(
@@ -2572,6 +2608,7 @@ export function TreatmentWorkspace({
activeDetailId={activeDetailId}
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
onTreatmentTypeChange={handleTreatmentTypeChange}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentDropdownCatalog}
@@ -2590,7 +2627,6 @@ export function TreatmentWorkspace({
const next = newDetail(seedFromAppointment);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
}}
onRemoveDetail={handleRemoveDetail}
onUploadFiles={(files, onProgress) =>
@@ -2619,14 +2655,125 @@ export function TreatmentWorkspace({
);
}}
showChrome
showFields={entryStep === 'treatment'}
showFields
hasArchJobs={activeHasArchJobs}
voice={voiceForEditor}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan}
chartLockMessage={t('selectTypeBeforeTeeth')}
chart={
entryStep === 'treatment' ? (
prosthesisAssignActive ? (
<ProsthesisAssignChart
className="w-full"
groups={
activeDetail?.toothSelectionGroups?.length
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail?.teeth ?? [])
}
toothProsthesis={
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ??
[]
}
detailClientId={activeDetailId}
catalog={prosthesisCatalog}
disabled={!canEditTreatmentForDay || activeLocked}
headerControl={
<button
type="button"
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
aria-pressed={showWholeTreatmentPlan}
className={`
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
showWholeTreatmentPlan
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('toothChartWholePlan')}
</button>
}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(
toothEdgeKey(a, b),
);
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
onChange={({ groups, toothProsthesis }) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: groups,
teeth: deriveTeethFromGroups(groups),
},
),
);
setLabCaseDrafts((prev) => {
const existing = prev.find(
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
);
if (existing) {
return prev.map((lc) =>
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
);
}
return [
...prev,
{
...newLabCaseDraft(),
detailClientId: activeDetailId,
toothProsthesis,
},
];
});
}}
/>
) : (
<FdiToothChart
className="w-full"
selected={chartSelectedTeeth}
@@ -2765,75 +2912,64 @@ export function TreatmentWorkspace({
activeLocked ||
!activeTypeSelected
}
onReset={() => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
if (deriveTeethFromGroups(currentGroups).length === 0) return;
if (!window.confirm(t('confirmResetChart'))) return;
const nextGroups = groupsFromFlatTeeth([]);
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: [],
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
/>
) : undefined
}
stepper={
showLabWizardStep ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={[
{ id: 'treatment', label: t('entryStepTreatment') },
{ id: 'lab', label: t('entryStepLab') },
]}
currentStepId={entryStep}
onStepChange={(stepId) => {
const next = stepId as EntryStep;
if (next === 'lab' && canEditTreatmentForDay) {
void persistDraft({ force: true })
.then(() => setEntryStep('lab'))
.catch((error: unknown) => {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
});
return;
}
setEntryStep(next);
}}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'treatment'}
onClick={() => setEntryStep('treatment')}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={entryStep === 'lab'}
onClick={async () => {
if (canEditTreatmentForDay) {
try {
await persistDraft({ force: true });
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
return;
}
}
setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
) : undefined
)
}
/>
{entryStep === 'lab' ? (
{showLabDispatch ? (
<div ref={labPanelRef} className="space-y-3">
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2857,11 +2993,6 @@ export function TreatmentWorkspace({
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</>