improvement: new prosthesis type data structure implemented and finally working!
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
349
frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx
Normal file
349
frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
477
frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx
Normal file
477
frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user