improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
|
||||
/** Primary-tint styling matching the previous custom connected pill. */
|
||||
const CONNECTED_BADGE_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||
color: 'var(--color-primary)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-primary) 40%, transparent)',
|
||||
} as const;
|
||||
|
||||
type ConnectedSelectionBadgeProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Compact “Connected” label — shared Badge with primary tint (not default variant colors). */
|
||||
export function ConnectedSelectionBadge({ className }: ConnectedSelectionBadgeProps) {
|
||||
const t = useTranslations('treatment');
|
||||
return (
|
||||
<Badge
|
||||
fixedWidth={false}
|
||||
className={`text-[10px] min-h-0 py-0.5 px-1.5 leading-none ${className ?? ''}`}
|
||||
style={CONNECTED_BADGE_STYLE}
|
||||
>
|
||||
{t('connectedBadge')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,9 @@ function quadrantMirrored(fdi: FdiToothId): boolean {
|
||||
|
||||
interface FdiToothChartProps {
|
||||
selected: ReadonlySet<FdiToothId>;
|
||||
onToggle?: (fdi: FdiToothId) => void;
|
||||
/** Teeth that belong to a connected selection span (dots above/below). */
|
||||
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
||||
disabled?: boolean;
|
||||
/** Non-interactive display (Cases / connection history). */
|
||||
readOnly?: boolean;
|
||||
@@ -38,6 +40,7 @@ interface FdiToothChartProps {
|
||||
|
||||
export function FdiToothChart({
|
||||
selected,
|
||||
connectedTeeth,
|
||||
onToggle,
|
||||
disabled,
|
||||
readOnly = false,
|
||||
@@ -147,6 +150,7 @@ export function FdiToothChart({
|
||||
const kind = getToothShapeKind(fdi);
|
||||
const gid = `${uid}-g-${fdi}-${i}`;
|
||||
const isSel = selected.has(fdi);
|
||||
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||
const size = toothSizeClass(fdi);
|
||||
const tweak = TOOTH_TWEAKS[fdi];
|
||||
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
||||
@@ -168,24 +172,44 @@ export function FdiToothChart({
|
||||
/>
|
||||
);
|
||||
|
||||
const connectedDot = isConnected ? (
|
||||
<span
|
||||
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
|
||||
aria-hidden
|
||||
title={t('toothConnectedHint')}
|
||||
/>
|
||||
) : (
|
||||
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
||||
{upper ? connectedDot : null}
|
||||
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onToggle?.(fdi)}
|
||||
onMouseDown={(e) => {
|
||||
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||
if (e.shiftKey) e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||
}}
|
||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||
className={`
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform
|
||||
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||
`}
|
||||
aria-pressed={isSel}
|
||||
aria-label={
|
||||
isSel
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
||||
isConnected ? t('toothConnectedSuffix') : ''
|
||||
}`
|
||||
: t('toothAria', { fdi })
|
||||
}
|
||||
>
|
||||
@@ -201,6 +225,7 @@ export function FdiToothChart({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!upper ? connectedDot : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -215,8 +240,8 @@ export function FdiToothChart({
|
||||
<>
|
||||
<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">
|
||||
<div className="relative isolate min-w-max mx-auto w-fit">
|
||||
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none">
|
||||
<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
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
||||
@@ -19,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi
|
||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
interface LabCasesDispatchPanelProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
@@ -56,32 +58,50 @@ function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function prosthesisTeethRows(
|
||||
type ProsthesisGroupRow = {
|
||||
groupId: string;
|
||||
kind: 'connected' | 'single';
|
||||
teeth: string[];
|
||||
detailClientId: string;
|
||||
detailNumber: number;
|
||||
};
|
||||
|
||||
function prosthesisGroupRows(
|
||||
labCase: LabCaseDraft,
|
||||
activeDetail: TreatmentDetailDraft,
|
||||
detailNumber: number,
|
||||
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
||||
): ProsthesisGroupRow[] {
|
||||
if (labCase.detailClientId !== activeDetail.clientId) return [];
|
||||
if (activeDetail.treatmentType !== 'prosthesis') return [];
|
||||
|
||||
return activeDetail.teeth.map((tooth) => ({
|
||||
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,
|
||||
tooth,
|
||||
detailNumber,
|
||||
}));
|
||||
}
|
||||
|
||||
function isProsthesisMapComplete(
|
||||
labCase: LabCaseDraft,
|
||||
rows: Array<{ detailClientId: string; tooth: string }>,
|
||||
rows: ProsthesisGroupRow[],
|
||||
): boolean {
|
||||
if (rows.length === 0) return true;
|
||||
return rows.every((row) =>
|
||||
labCase.toothProsthesis.some(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === row.tooth &&
|
||||
Boolean(tp.prosthesisTypeCode),
|
||||
row.teeth.every((tooth) =>
|
||||
labCase.toothProsthesis.some(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === tooth &&
|
||||
tp.selectionGroupId === row.groupId &&
|
||||
Boolean(tp.prosthesisTypeCode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -148,11 +168,12 @@ export function LabCasesDispatchPanel({
|
||||
: null;
|
||||
|
||||
const prosthesisRows = activeLabCase && activeDetail
|
||||
? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber)
|
||||
? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber)
|
||||
: [];
|
||||
const prosthesisComplete = activeLabCase
|
||||
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
|
||||
: true;
|
||||
const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLabCase?.destinationOrganizationId) {
|
||||
@@ -196,28 +217,36 @@ export function LabCasesDispatchPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function setToothProsthesis(
|
||||
detailClientId: string,
|
||||
tooth: string,
|
||||
prosthesisTypeCode: string,
|
||||
) {
|
||||
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
|
||||
if (!activeLabCase) return;
|
||||
const toothSet = new Set(row.teeth);
|
||||
const rest = activeLabCase.toothProsthesis.filter(
|
||||
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
|
||||
(tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)),
|
||||
);
|
||||
const next = prosthesisTypeCode
|
||||
? [...rest, { detailClientId, tooth, 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;
|
||||
const next = prosthesisRows.map((row) => ({
|
||||
detailClientId: row.detailClientId,
|
||||
tooth: row.tooth,
|
||||
prosthesisTypeCode: code,
|
||||
}));
|
||||
const next = prosthesisRows.flatMap((row) =>
|
||||
row.teeth.map((tooth) => ({
|
||||
detailClientId: row.detailClientId,
|
||||
tooth,
|
||||
prosthesisTypeCode: code,
|
||||
selectionGroupId: row.groupId,
|
||||
})),
|
||||
);
|
||||
updateActiveLabCase({ toothProsthesis: next });
|
||||
}
|
||||
|
||||
@@ -316,49 +345,55 @@ export function LabCasesDispatchPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const byType = new Map<string, string[]>();
|
||||
for (const tp of activeLabCase.toothProsthesis) {
|
||||
if (tp.detailClientId !== activeDetail.clientId) continue;
|
||||
if (!tp.prosthesisTypeCode) continue;
|
||||
const list = byType.get(tp.prosthesisTypeCode) ?? [];
|
||||
list.push(tp.tooth);
|
||||
byType.set(tp.prosthesisTypeCode, list);
|
||||
}
|
||||
const selectionGroups =
|
||||
activeDetail.toothSelectionGroups.length > 0
|
||||
? activeDetail.toothSelectionGroups
|
||||
: groupsFromFlatTeeth(activeDetail.teeth);
|
||||
|
||||
const uniqueSelected = [...new Set(activeDetail.teeth)];
|
||||
const mappedTeeth = new Set<string>();
|
||||
for (const teeth of byType.values()) {
|
||||
for (const tooth of teeth) mappedTeeth.add(tooth);
|
||||
}
|
||||
const unmapped = uniqueSelected.filter((t) => !mappedTeeth.has(t));
|
||||
|
||||
const groups = [...byType.entries()]
|
||||
.map(([code, teeth]) => ({
|
||||
const rows = selectionGroups.map((group) => {
|
||||
const codes = new Set(
|
||||
activeLabCase.toothProsthesis
|
||||
.filter(
|
||||
(tp) =>
|
||||
tp.detailClientId === activeDetail.clientId &&
|
||||
group.teeth.includes(tp.tooth as never) &&
|
||||
tp.prosthesisTypeCode,
|
||||
)
|
||||
.map((tp) => tp.prosthesisTypeCode),
|
||||
);
|
||||
const code = codes.size === 1 ? [...codes][0] : '';
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
kind: group.kind,
|
||||
teeth: group.teeth,
|
||||
code,
|
||||
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b)),
|
||||
label: prosthesisOptions.find((p) => p.code === code)?.label ?? code,
|
||||
}))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
label: code
|
||||
? prosthesisOptions.find((p) => p.code === code)?.label ?? code
|
||||
: t('prosthesisUnassigned'),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
|
||||
<p className="text-text-primary">
|
||||
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
|
||||
</p>
|
||||
{groups.map((g) => (
|
||||
{rows.map((g) => (
|
||||
<p
|
||||
key={g.code}
|
||||
key={g.groupId}
|
||||
className="text-[13px]"
|
||||
style={{ color: prosthesisTypeColorFromCatalog(g.code, prosthesisOptions) }}
|
||||
style={{
|
||||
color: g.code
|
||||
? prosthesisTypeColorFromCatalog(g.code, prosthesisOptions)
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{g.kind === 'connected' ? (
|
||||
<ConnectedSelectionBadge className="me-1 align-middle" />
|
||||
) : null}
|
||||
{g.label}: <span className="text-text-primary">{g.teeth.join(', ')}</span>
|
||||
</p>
|
||||
))}
|
||||
{unmapped.length > 0 ? (
|
||||
<p className="text-[13px] text-text-muted">
|
||||
{t('prosthesisUnassigned')}: <span className="text-text-primary">{unmapped.join(', ')}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -514,75 +549,82 @@ export function LabCasesDispatchPanel({
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
{t('prosthesisTypesTitle')}
|
||||
</p>
|
||||
<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>
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted">
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
|
||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
|
||||
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prosthesisRows.map((row) => {
|
||||
const current =
|
||||
activeLabCase.toothProsthesis.find(
|
||||
(tp) =>
|
||||
tp.detailClientId === row.detailClientId &&
|
||||
tp.tooth === row.tooth,
|
||||
)?.prosthesisTypeCode ?? '';
|
||||
return (
|
||||
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
|
||||
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
|
||||
<td className="py-2 pr-3 text-text-secondary">
|
||||
{t('detailLabel', { n: row.detailNumber })}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setToothProsthesis(
|
||||
row.detailClientId,
|
||||
row.tooth,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full min-w-0 sm:min-w-[160px]`}
|
||||
>
|
||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||
{prosthesisOptions.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
|
||||
<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 space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
|
||||
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
||||
<span>
|
||||
{row.kind === 'connected'
|
||||
? t('prosthesisConnectedLabel')
|
||||
: t('prosthesisColTooth')}
|
||||
{': '}
|
||||
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
· {t('detailLabel', { n: row.detailNumber })}
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||
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>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import {
|
||||
autosaveStatusClass,
|
||||
labPendingBannerClass,
|
||||
labSentBannerClass,
|
||||
labBlockedBannerClass,
|
||||
} from '@/components/treatment/treatmentStatusStyles';
|
||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
@@ -17,7 +19,6 @@ import {
|
||||
isDetailTypeSelected,
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||
|
||||
interface TreatmentDetailsEditorProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
@@ -32,8 +33,12 @@ interface TreatmentDetailsEditorProps {
|
||||
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||
uploadBusy: boolean;
|
||||
onAddDetail: () => void;
|
||||
onRemoveDetail: () => void;
|
||||
onRemoveDetail?: (detailClientId: string) => void;
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
/** Detail chips + Add detail (default true). */
|
||||
showChrome?: boolean;
|
||||
/** Type / notes / attachments fields (default true). */
|
||||
showFields?: boolean;
|
||||
}
|
||||
|
||||
export function TreatmentDetailsEditor({
|
||||
@@ -51,6 +56,8 @@ export function TreatmentDetailsEditor({
|
||||
onAddDetail,
|
||||
onRemoveDetail,
|
||||
onUploadFiles,
|
||||
showChrome = true,
|
||||
showFields = true,
|
||||
}: TreatmentDetailsEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -61,7 +68,6 @@ export function TreatmentDetailsEditor({
|
||||
|
||||
const locked = isDetailLocked(activeDetail);
|
||||
const readOnly = disabled || locked;
|
||||
const canRemoveDetail = canEdit && !disabled && !locked && details.length > 1;
|
||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
||||
? treatmentTypeColor(
|
||||
activeDetail.treatmentType,
|
||||
@@ -75,159 +81,191 @@ export function TreatmentDetailsEditor({
|
||||
labDependentCodes,
|
||||
);
|
||||
|
||||
if (!showChrome && !showFields) return null;
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.map((d, idx) => (
|
||||
<button
|
||||
key={d.clientId}
|
||||
type="button"
|
||||
onClick={() => onActiveDetailChange(d.clientId)}
|
||||
className={`
|
||||
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
||||
${
|
||||
d.clientId === activeDetailId
|
||||
? '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('detailLabel', { n: idx + 1 })}
|
||||
{isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
{locked && (
|
||||
<p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>
|
||||
)}
|
||||
{showPendingLabHint && (
|
||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
||||
)}
|
||||
{showMissingTeethLabBlock && (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
)}
|
||||
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
{t('comments')}
|
||||
<textarea
|
||||
value={activeDetail.comment}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onDetailsChange(
|
||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
||||
);
|
||||
}}
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={5}
|
||||
disabled={readOnly}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Dropdown
|
||||
label={t('treatmentType')}
|
||||
value={activeDetail.treatmentType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
|
||||
onDetailsChange(
|
||||
details.map((d) =>
|
||||
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
style={{ color: treatmentTypeTextColor }}
|
||||
>
|
||||
<option value="">{t('treatmentTypePlaceholder')}</option>
|
||||
{treatmentCatalog.map((entry, index) => (
|
||||
<option
|
||||
key={entry.code}
|
||||
value={entry.code}
|
||||
style={{
|
||||
color: treatmentTypeColor(entry.code, index),
|
||||
backgroundColor: '#14253d',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
id="treatment-detail-attachments"
|
||||
type="file"
|
||||
multiple
|
||||
disabled={readOnly || uploadBusy}
|
||||
onChange={(e) => {
|
||||
onUploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label={t('attachFiles')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={readOnly || uploadBusy}
|
||||
isLoading={uploadBusy}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-detail-attachments"
|
||||
>
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
{activeDetail.attachmentMetas.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||
{activeDetail.attachmentMetas.map((f) => (
|
||||
<li key={f.id} className="truncate">
|
||||
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canRemoveDetail ? (
|
||||
<div className="pt-1">
|
||||
{showChrome ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={onRemoveDetail}
|
||||
disabled={uploadBusy}
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && saveStatus !== 'idle' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.map((d, idx) => {
|
||||
const detailLocked = isDetailLocked(d);
|
||||
const isActive = d.clientId === activeDetailId;
|
||||
// Same rules as the former Content-step delete button:
|
||||
// only when more than one detail remains; disabled if no edit, day-locked, sent, or uploading.
|
||||
const showRemoveAction = details.length > 1;
|
||||
const removeDisabled =
|
||||
!canEdit || disabled || detailLocked || uploadBusy;
|
||||
return (
|
||||
<div
|
||||
key={d.clientId}
|
||||
className={`
|
||||
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border
|
||||
${
|
||||
isActive
|
||||
? 'border-primary bg-primary-soft'
|
||||
: 'border-border/70 hover:border-border hover:bg-background-card/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveDetailChange(d.clientId)}
|
||||
className={`
|
||||
px-3 py-1.5 text-sm transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45
|
||||
${isActive ? 'font-medium text-text-primary' : 'text-text-secondary'}
|
||||
`}
|
||||
>
|
||||
{t('detailLabel', { n: idx + 1 })}
|
||||
{detailLocked ? ` · ${t('detailSentBadge')}` : ''}
|
||||
</button>
|
||||
{showRemoveAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (removeDisabled) return;
|
||||
onRemoveDetail?.(d.clientId);
|
||||
}}
|
||||
disabled={removeDisabled}
|
||||
title={tCommon('delete')}
|
||||
aria-label={t('removeDetailAria', { n: idx + 1 })}
|
||||
className={`
|
||||
inline-flex items-center justify-center border-s px-1.5 transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
|
||||
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-inherit
|
||||
${
|
||||
isActive
|
||||
? 'border-primary/30 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
||||
: 'border-border/60 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showFields ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||
{locked && <p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>}
|
||||
{showPendingLabHint && (
|
||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
||||
)}
|
||||
{showMissingTeethLabBlock && (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Dropdown
|
||||
label={t('treatmentType')}
|
||||
value={activeDetail.treatmentType}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
|
||||
onDetailsChange(
|
||||
details.map((d) =>
|
||||
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
style={{ color: treatmentTypeTextColor }}
|
||||
>
|
||||
<option value="">{t('treatmentTypePlaceholder')}</option>
|
||||
{treatmentCatalog.map((entry, index) => (
|
||||
<option
|
||||
key={entry.code}
|
||||
value={entry.code}
|
||||
style={{
|
||||
color: treatmentTypeColor(entry.code, index),
|
||||
backgroundColor: '#14253d',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
{t('comments')}
|
||||
<textarea
|
||||
value={activeDetail.comment}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onDetailsChange(
|
||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
||||
);
|
||||
}}
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={5}
|
||||
disabled={readOnly}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
id="treatment-detail-attachments"
|
||||
type="file"
|
||||
multiple
|
||||
disabled={readOnly || uploadBusy}
|
||||
onChange={(e) => {
|
||||
onUploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label={t('attachFiles')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={readOnly || uploadBusy}
|
||||
isLoading={uploadBusy}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-detail-attachments"
|
||||
>
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
{activeDetail.attachmentMetas.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||
{activeDetail.attachmentMetas.map((f) => (
|
||||
<li key={f.id} className="truncate">
|
||||
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showFields && canEdit && saveStatus !== 'idle' && (
|
||||
<p
|
||||
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
||||
role="status"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
import {
|
||||
TreatmentLabCasesPanel,
|
||||
@@ -39,6 +40,14 @@ import {
|
||||
isEmptyDraftDetail,
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
import {
|
||||
applyShiftRange,
|
||||
connectedTeethSet,
|
||||
deriveTeethFromGroups,
|
||||
groupsFromFlatTeeth,
|
||||
pruneToothProsthesisForGroups,
|
||||
toggleToothInGroups,
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||
@@ -68,6 +77,9 @@ import type {
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
|
||||
type WorkspaceMode = 'live' | 'historical';
|
||||
type EntryStep = 'teeth' | 'content' | 'lab';
|
||||
|
||||
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
|
||||
|
||||
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
|
||||
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
|
||||
@@ -98,6 +110,7 @@ function labCaseDraftsToPast(
|
||||
clientId: linkedDetail.clientId,
|
||||
treatmentType: linkedDetail.treatmentType,
|
||||
teeth: linkedDetail.teeth,
|
||||
toothSelectionGroups: linkedDetail.toothSelectionGroups,
|
||||
}
|
||||
: null,
|
||||
sends: lc.sends ?? [],
|
||||
@@ -154,6 +167,7 @@ function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
|
||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
treatmentType: defaultTreatmentType ?? '',
|
||||
teeth: [],
|
||||
toothSelectionGroups: [],
|
||||
comment: '',
|
||||
attachmentMetas: [],
|
||||
sendToOrganizationIds: [],
|
||||
@@ -190,11 +204,14 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
|
||||
}
|
||||
|
||||
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||
const teeth = d.teeth;
|
||||
const toothSelectionGroups = groupsFromFlatTeeth(teeth, d.toothSelectionGroups ?? null);
|
||||
return {
|
||||
clientId: d.clientId,
|
||||
id: d.id,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
teeth,
|
||||
toothSelectionGroups,
|
||||
comment: d.notes ?? '',
|
||||
attachmentMetas: d.attachmentMetas ?? [],
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
@@ -215,6 +232,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId ?? '',
|
||||
})),
|
||||
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
||||
sentAt: lc.sentAt ?? null,
|
||||
@@ -231,6 +249,7 @@ function serializeDetails(details: TreatmentDetailDraft[]) {
|
||||
id: d.id,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
toothSelectionGroups: d.toothSelectionGroups,
|
||||
comment: d.comment,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
})),
|
||||
@@ -261,6 +280,7 @@ function detailsToPreviewTreatment(
|
||||
clientId: d.clientId,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
toothSelectionGroups: d.toothSelectionGroups,
|
||||
notes: d.comment || null,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
@@ -380,6 +400,7 @@ export function TreatmentWorkspace({
|
||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
||||
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
||||
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
||||
const [entryStep, setEntryStep] = useState<EntryStep>('teeth');
|
||||
|
||||
const isDetailLocked = useCallback(
|
||||
(detail: TreatmentDetailDraft) =>
|
||||
@@ -478,6 +499,20 @@ export function TreatmentWorkspace({
|
||||
[details, labDependentCodes],
|
||||
);
|
||||
|
||||
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
|
||||
const showLabWizardStep = useMemo(
|
||||
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
||||
[activeDetail, labDependentCodes],
|
||||
);
|
||||
|
||||
const visibleEntrySteps = useMemo(
|
||||
() =>
|
||||
showLabWizardStep
|
||||
? ENTRY_STEPS
|
||||
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
|
||||
[showLabWizardStep],
|
||||
);
|
||||
|
||||
const showLabShipmentBlocked = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
@@ -546,6 +581,11 @@ export function TreatmentWorkspace({
|
||||
}, []);
|
||||
|
||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||
const connectedSelectedTeeth = useMemo(
|
||||
() => connectedTeethSet(activeDetail?.toothSelectionGroups ?? []),
|
||||
[activeDetail?.toothSelectionGroups],
|
||||
);
|
||||
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
||||
|
||||
const wholePlanTeethSet = useMemo(() => {
|
||||
const set = new Set<FdiToothId>();
|
||||
@@ -574,8 +614,17 @@ export function TreatmentWorkspace({
|
||||
// Reset whole-plan overview when switching details.
|
||||
useEffect(() => {
|
||||
setShowWholeTreatmentPlan(false);
|
||||
rangeAnchorRef.current = null;
|
||||
setEntryStep('teeth');
|
||||
}, [activeDetailId]);
|
||||
|
||||
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||
useEffect(() => {
|
||||
if (entryStep === 'lab' && !showLabWizardStep) {
|
||||
setEntryStep('content');
|
||||
}
|
||||
}, [entryStep, showLabWizardStep]);
|
||||
|
||||
// Sync active lab shipment when the selected treatment detail changes.
|
||||
useEffect(() => {
|
||||
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
|
||||
@@ -864,14 +913,17 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
|
||||
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
||||
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
|
||||
clientId,
|
||||
id,
|
||||
treatmentType,
|
||||
teeth,
|
||||
comment,
|
||||
attachmentIds: attachmentMetas.map((a) => a.id),
|
||||
})),
|
||||
details: currentDetails.map(
|
||||
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
|
||||
clientId,
|
||||
id,
|
||||
treatmentType,
|
||||
teeth,
|
||||
toothSelectionGroups,
|
||||
comment,
|
||||
attachmentIds: attachmentMetas.map((a) => a.id),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
setDetails(mapped);
|
||||
@@ -1081,6 +1133,7 @@ export function TreatmentWorkspace({
|
||||
setActiveLabCaseId(linked.clientId);
|
||||
}
|
||||
if (options?.scrollToLabPanel !== false) {
|
||||
setEntryStep('lab');
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
@@ -1140,6 +1193,7 @@ export function TreatmentWorkspace({
|
||||
if (linked) {
|
||||
setActiveLabCaseId(linked.clientId);
|
||||
}
|
||||
setEntryStep('lab');
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
@@ -1306,11 +1360,18 @@ export function TreatmentWorkspace({
|
||||
treatmentDetailId: detailId,
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId ?? '',
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } =>
|
||||
row !== null,
|
||||
(
|
||||
row,
|
||||
): row is {
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId: string;
|
||||
} => row !== null,
|
||||
),
|
||||
attachmentIds: lc.attachmentIds,
|
||||
dueDate: lc.dueDate ?? null,
|
||||
@@ -1379,35 +1440,42 @@ export function TreatmentWorkspace({
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveActiveDetail = useCallback(() => {
|
||||
if (!canEditTreatmentForDay) return;
|
||||
const idx = details.findIndex((d) => d.clientId === activeDetailId);
|
||||
if (idx < 0) return;
|
||||
const active = details[idx];
|
||||
if (!active || isDetailLocked(active) || details.length <= 1) return;
|
||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||
const handleRemoveDetail = useCallback(
|
||||
(detailClientId: string) => {
|
||||
if (!canEditTreatmentForDay) return;
|
||||
const idx = details.findIndex((d) => d.clientId === detailClientId);
|
||||
if (idx < 0) return;
|
||||
const target = details[idx];
|
||||
if (!target || isDetailLocked(target) || details.length <= 1) return;
|
||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||
|
||||
const removedId = active.clientId;
|
||||
const nextDetails = details.filter((d) => d.clientId !== removedId);
|
||||
const nextActive =
|
||||
nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? nextDetails[0]?.clientId;
|
||||
setDetails(nextDetails);
|
||||
if (nextActive) setActiveDetailId(nextActive);
|
||||
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||
const nextActive =
|
||||
activeDetailId === detailClientId
|
||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
||||
nextDetails[0]?.clientId)
|
||||
: activeDetailId;
|
||||
setDetails(nextDetails);
|
||||
if (nextActive) setActiveDetailId(nextActive);
|
||||
|
||||
if (labCaseDrafts.some((lc) => lc.detailClientId === removedId)) {
|
||||
handleLabCasesChange(
|
||||
withoutEmptyLabCaseDrafts(labCaseDrafts.filter((lc) => lc.detailClientId !== removedId)),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
details,
|
||||
handleLabCasesChange,
|
||||
isDetailLocked,
|
||||
labCaseDrafts,
|
||||
t,
|
||||
]);
|
||||
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
||||
handleLabCasesChange(
|
||||
withoutEmptyLabCaseDrafts(
|
||||
labCaseDrafts.filter((lc) => lc.detailClientId !== detailClientId),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
details,
|
||||
handleLabCasesChange,
|
||||
isDetailLocked,
|
||||
labCaseDrafts,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddLabCase = useCallback(async () => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
@@ -1764,35 +1832,6 @@ export function TreatmentWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 min-w-0 w-full">
|
||||
<FdiToothChart
|
||||
selected={chartSelectedTeeth}
|
||||
toothColors={chartToothColors}
|
||||
readOnly={showWholeTreatmentPlan}
|
||||
headerControl={
|
||||
details.length > 1 ? (
|
||||
<Checkbox
|
||||
checked={showWholeTreatmentPlan}
|
||||
onChange={setShowWholeTreatmentPlan}
|
||||
label={t('toothChartWholePlan')}
|
||||
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onToggle={(fdi) => {
|
||||
if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
|
||||
setDetails((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.clientId !== activeDetailId) return d;
|
||||
const set = new Set(d.teeth);
|
||||
if (set.has(fdi)) set.delete(fdi);
|
||||
else set.add(fdi);
|
||||
return { ...d, teeth: [...set].sort() as FdiToothId[] };
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
||||
/>
|
||||
|
||||
<TreatmentDetailsEditor
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
@@ -1811,60 +1850,223 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
setEntryStep('teeth');
|
||||
}}
|
||||
onRemoveDetail={handleRemoveActiveDetail}
|
||||
onRemoveDetail={handleRemoveDetail}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
showChrome
|
||||
showFields={false}
|
||||
/>
|
||||
|
||||
<div ref={labPanelRef}>
|
||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||
{showLabDispatchPanel ? (
|
||||
<LabCasesDispatchPanel
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
labCases={labCaseDrafts}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
labCaseSummary={activeLabCaseSummary}
|
||||
locale={locale}
|
||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
||||
onLabCaseActivityChange={() => {
|
||||
if (historyPatientId) {
|
||||
void refreshPatientLabCases(historyPatientId, { silent: true });
|
||||
}
|
||||
}}
|
||||
activeLabCaseId={activeLabCaseId}
|
||||
onLabCasesChange={handleLabCasesChange}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
orgs={orgs}
|
||||
organizationSearch={organizationSearch}
|
||||
onOrganizationSearchChange={setOrganizationSearch}
|
||||
recentOrganizationIds={recentOrganizationIds}
|
||||
onRecentOrganizationPick={(orgId) => {
|
||||
setLabCaseDrafts((prev) => {
|
||||
const targetId =
|
||||
activeLabCaseId ??
|
||||
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
|
||||
?.clientId;
|
||||
if (!targetId) return prev;
|
||||
return prev.map((lc) =>
|
||||
lc.clientId === targetId && !lc.sentAt
|
||||
? { ...lc, destinationOrganizationId: orgId }
|
||||
: lc,
|
||||
);
|
||||
});
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
|
||||
onCommentError={showError}
|
||||
canInviteLab={canAccessOrganizations}
|
||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||
/>
|
||||
) : null}
|
||||
<div className="surface-card px-3 py-2 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={visibleEntrySteps.map((step) => ({
|
||||
id: step,
|
||||
label:
|
||||
step === 'teeth'
|
||||
? t('entryStepTeeth')
|
||||
: step === 'content'
|
||||
? t('entryStepContent')
|
||||
: t('entryStepLab'),
|
||||
}))}
|
||||
currentStepId={entryStep}
|
||||
onStepChange={(stepId) => setEntryStep(stepId as EntryStep)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={entryStep === 'teeth'}
|
||||
onClick={() => {
|
||||
const idx = visibleEntrySteps.indexOf(entryStep);
|
||||
if (idx > 0) setEntryStep(visibleEntrySteps[idx - 1]);
|
||||
}}
|
||||
>
|
||||
{t('entryStepBack')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={
|
||||
entryStep === 'lab' || (entryStep === 'content' && !showLabWizardStep)
|
||||
}
|
||||
onClick={() => {
|
||||
if (entryStep === 'teeth') setEntryStep('content');
|
||||
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
|
||||
}}
|
||||
>
|
||||
{t('entryStepNext')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entryStep === 'teeth' ? (
|
||||
<FdiToothChart
|
||||
selected={chartSelectedTeeth}
|
||||
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
||||
toothColors={chartToothColors}
|
||||
readOnly={showWholeTreatmentPlan}
|
||||
headerControl={
|
||||
details.length > 1 ? (
|
||||
<Checkbox
|
||||
checked={showWholeTreatmentPlan}
|
||||
onChange={setShowWholeTreatmentPlan}
|
||||
label={t('toothChartWholePlan')}
|
||||
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onToggle={(fdi, event) => {
|
||||
if (
|
||||
!canEditTreatmentForDay ||
|
||||
isDetailLocked(activeDetail) ||
|
||||
showWholeTreatmentPlan ||
|
||||
!activeDetailId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = details.find((d) => d.clientId === activeDetailId);
|
||||
if (!detail) return;
|
||||
const currentGroups =
|
||||
detail.toothSelectionGroups.length > 0
|
||||
? detail.toothSelectionGroups
|
||||
: groupsFromFlatTeeth(detail.teeth);
|
||||
|
||||
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
const anchor = rangeAnchorRef.current;
|
||||
// Need a prior click as range start; shift alone on one tooth does nothing.
|
||||
if (!anchor || anchor === fdi) {
|
||||
rangeAnchorRef.current = fdi;
|
||||
return;
|
||||
}
|
||||
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
if (!nextGroups) return;
|
||||
} else {
|
||||
nextGroups = toggleToothInGroups(currentGroups, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
}
|
||||
|
||||
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!,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{entryStep === 'content' ? (
|
||||
<TreatmentDetailsEditor
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
onActiveDetailChange={setActiveDetailId}
|
||||
onDetailsChange={setDetails}
|
||||
isDetailLocked={isDetailLocked}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentDropdownCatalog}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
saveStatus={saveStatus}
|
||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||
onAddDetail={() => {
|
||||
const next = newDetail(
|
||||
defaultTreatmentTypeForAppointment(
|
||||
selectedAppointment?.purpose,
|
||||
treatmentCatalog,
|
||||
),
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
setEntryStep('teeth');
|
||||
}}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
showChrome={false}
|
||||
showFields
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{entryStep === 'lab' ? (
|
||||
<div ref={labPanelRef}>
|
||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||
{showLabDispatchPanel ? (
|
||||
<LabCasesDispatchPanel
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
labCases={labCaseDrafts}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
labCaseSummary={activeLabCaseSummary}
|
||||
locale={locale}
|
||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
||||
onLabCaseActivityChange={() => {
|
||||
if (historyPatientId) {
|
||||
void refreshPatientLabCases(historyPatientId, { silent: true });
|
||||
}
|
||||
}}
|
||||
activeLabCaseId={activeLabCaseId}
|
||||
onLabCasesChange={handleLabCasesChange}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
canEdit={canEdit}
|
||||
orgs={orgs}
|
||||
organizationSearch={organizationSearch}
|
||||
onOrganizationSearchChange={setOrganizationSearch}
|
||||
recentOrganizationIds={recentOrganizationIds}
|
||||
onRecentOrganizationPick={(orgId) => {
|
||||
setLabCaseDrafts((prev) => {
|
||||
const targetId =
|
||||
activeLabCaseId ??
|
||||
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
|
||||
?.clientId;
|
||||
if (!targetId) return prev;
|
||||
return prev.map((lc) =>
|
||||
lc.clientId === targetId && !lc.sentAt
|
||||
? { ...lc, destinationOrganizationId: orgId }
|
||||
: lc,
|
||||
);
|
||||
});
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
|
||||
onCommentError={showError}
|
||||
canInviteLab={canAccessOrganizations}
|
||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted surface-card p-4">
|
||||
{t('entryStepLabUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user