improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.

This commit is contained in:
2026-07-17 00:39:32 +03:30
parent cf07b4d8a8
commit 68fc9d5d6d
28 changed files with 1461 additions and 477 deletions

View File

@@ -661,6 +661,7 @@
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
@@ -799,8 +800,19 @@
"noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart",
"entryWizardLabel": "Treatment entry steps",
"entryStepTeeth": "Teeth",
"entryStepContent": "Content",
"entryStepLab": "Lab",
"entryStepBack": "Back",
"entryStepNext": "Next",
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to multi-select. Applies to the active detail.",
"toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.",
"toothConnectedHint": "Connected selection",
"toothConnectedSuffix": " (connected)",
"connectedBadge": "Connected",
"prosthesisConnectedLabel": "Connected teeth prosthesis type",
"toothChartWholePlan": "Full View",
"labShipmentAttachments": "Files for the lab",
"labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.",

View File

@@ -662,6 +662,7 @@
"detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.",
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
@@ -801,7 +802,18 @@
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.",
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد.",
"toothConnectedHint": "انتخاب متصل",
"toothConnectedSuffix": " (متصل)",
"connectedBadge": "متصل",
"prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل",
"entryWizardLabel": "مراحل ثبت درمان",
"entryStepTeeth": "دندان‌ها",
"entryStepContent": "محتوا",
"entryStepLab": "لابراتوار",
"entryStepBack": "قبلی",
"entryStepNext": "بعدی",
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
"toothChartWholePlan": "نمایش کامل",
"labShipmentAttachments": "فایل‌ها برای لابراتوار",
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.",

View File

@@ -661,6 +661,7 @@
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
@@ -800,7 +801,18 @@
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
"toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.",
"toothConnectedHint": "Verbonden selectie",
"toothConnectedSuffix": " (verbonden)",
"connectedBadge": "Verbonden",
"prosthesisConnectedLabel": "Prothesetype voor verbonden tanden",
"entryWizardLabel": "Behandeling invoerstappen",
"entryStepTeeth": "Tanden",
"entryStepContent": "Inhoud",
"entryStepLab": "Lab",
"entryStepBack": "Terug",
"entryStepNext": "Volgende",
"entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.",
"toothChartWholePlan": "Volledig overzicht",
"labShipmentAttachments": "Bestanden voor het lab",
"labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.",

View File

@@ -0,0 +1,189 @@
import type { FdiToothId } from '@/types/treatment';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT } from '@/components/treatment/fdiToothMeta';
export type ToothSelectionKind = 'connected' | 'single';
export type ToothSelectionGroup = {
groupId: string;
kind: ToothSelectionKind;
teeth: FdiToothId[];
};
function newGroupId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `tg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId[] {
const set = new Set<FdiToothId>();
for (const group of groups) {
for (const tooth of group.teeth) set.add(tooth);
}
return [...set].sort() as FdiToothId[];
}
/** Legacy / empty groups: each selected tooth becomes its own single group. */
export function groupsFromFlatTeeth(
teeth: readonly FdiToothId[],
existing?: ToothSelectionGroup[] | null,
): ToothSelectionGroup[] {
if (existing && existing.length > 0) {
const flat = new Set(teeth);
const next = existing
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
const covered = new Set(next.flatMap((g) => g.teeth));
for (const tooth of teeth) {
if (!covered.has(tooth)) {
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
}
}
return next;
}
return teeth.map((tooth) => ({
groupId: newGroupId(),
kind: 'single' as const,
teeth: [tooth],
}));
}
export function connectedTeethSet(groups: ToothSelectionGroup[]): Set<FdiToothId> {
const set = new Set<FdiToothId>();
for (const group of groups) {
if (group.kind !== 'connected') continue;
for (const tooth of group.teeth) set.add(tooth);
}
return set;
}
function archOrder(tooth: FdiToothId): FdiToothId[] | null {
if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
return FDI_UPPER_LEFT_TO_RIGHT;
}
if ((FDI_LOWER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
return FDI_LOWER_LEFT_TO_RIGHT;
}
return null;
}
export function sameArch(a: FdiToothId, b: FdiToothId): boolean {
const archA = archOrder(a);
const archB = archOrder(b);
return Boolean(archA && archB && archA === archB);
}
export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[] | null {
const arch = archOrder(a);
if (!arch || !sameArch(a, b)) return null;
const i = arch.indexOf(a);
const j = arch.indexOf(b);
if (i < 0 || j < 0) return null;
const [from, to] = i <= j ? [i, j] : [j, i];
return arch.slice(from, to + 1);
}
/**
* Plain click:
* - unselected → add as single
* - selected single → remove
* - selected in connected → drop the whole connected span and keep only this tooth as a single
*/
export function toggleToothInGroups(
groups: ToothSelectionGroup[],
tooth: FdiToothId,
): ToothSelectionGroup[] {
const owning = groups.find((g) => g.teeth.includes(tooth));
if (!owning) {
return [...groups, { groupId: newGroupId(), kind: 'single', teeth: [tooth] }];
}
if (owning.kind === 'single') {
return groups.filter((g) => g.groupId !== owning.groupId);
}
// Collapse connected span: only the clicked tooth remains, as a separate single.
const next = groups.filter((g) => g.groupId !== owning.groupId);
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
return next;
}
/**
* Shift-click creates a connected span between anchor and target on the same arch.
* Any existing groups that intersect the span are replaced by the new connected group.
*/
export function applyShiftRange(
groups: ToothSelectionGroup[],
anchor: FdiToothId,
target: FdiToothId,
): ToothSelectionGroup[] | null {
if (anchor === target) return null;
const span = teethBetweenInclusive(anchor, target);
if (!span || span.length < 2) return null;
const spanSet = new Set(span);
const next = groups
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
next.push({ groupId: newGroupId(), kind: 'connected', teeth: span });
return next;
}
/**
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
* selectionGroupId so connected→single collapses do not leave stale group ids.
*/
export function pruneToothProsthesisForGroups<
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
>(toothProsthesis: T[], detailClientId: string, groups: ToothSelectionGroup[]): T[] {
const toothToGroup = new Map<string, ToothSelectionGroup>();
for (const group of groups) {
for (const tooth of group.teeth) toothToGroup.set(tooth, group);
}
return toothProsthesis
.filter((tp) => tp.detailClientId !== detailClientId || toothToGroup.has(tp.tooth))
.map((tp) => {
if (tp.detailClientId !== detailClientId) return tp;
const group = toothToGroup.get(tp.tooth);
if (!group) return tp;
return { ...tp, selectionGroupId: group.groupId };
});
}
export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGroup[] {
if (!Array.isArray(value)) return [];
const out: ToothSelectionGroup[] = [];
for (const row of value) {
if (!row || typeof row !== 'object') continue;
const rec = row as Record<string, unknown>;
const groupId = typeof rec.groupId === 'string' ? rec.groupId : newGroupId();
const kind = rec.kind === 'connected' ? 'connected' : 'single';
const teeth = Array.isArray(rec.teeth)
? (rec.teeth.filter((t) => typeof t === 'string') as FdiToothId[])
: [];
if (teeth.length === 0) continue;
out.push({
groupId,
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
teeth,
});
}
return out;
}

View File

@@ -1,5 +1,6 @@
'use client';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import {
formatToothList,
prosthesisTypeColorFromCatalog,
@@ -9,6 +10,8 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
export type LabCaseProsthesisGroup = {
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
};
interface LabCaseProsthesisGroupsListProps {
@@ -31,12 +34,15 @@ export function LabCaseProsthesisGroupsList({
<ul className="space-y-0.5">
{groups.map((group) => (
<li
key={group.prosthesisTypeCode}
key={`${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}-${group.teeth.join(',')}`}
className="text-[11px] leading-snug"
style={{
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
}}
>
{group.connected ? (
<ConnectedSelectionBadge className="me-1 align-middle text-[9px] px-1 py-px" />
) : null}
<span className="font-medium">
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
</span>

View File

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

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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"

View File

@@ -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>

View File

@@ -5,6 +5,8 @@ export interface LabCaseAccessProsthesisGroup {
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}
export interface LabCaseAccessSession {

View File

@@ -3,6 +3,8 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
export interface LabCaseProsthesisGroup {
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}
export interface LabCaseListItem {

View File

@@ -20,6 +20,8 @@ export interface LabCaseActivityItem {
export interface PatientLabCaseProsthesisGroup {
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}
export interface PatientLabCaseSummary {

View File

@@ -58,6 +58,16 @@ export interface LabCaseToothProsthesisDraft {
detailClientId: string;
tooth: string;
prosthesisTypeCode: string;
/** Links connected/single selection group for shared prosthesis + lab tasks. */
selectionGroupId?: string;
}
export type ToothSelectionKind = 'connected' | 'single';
export interface ToothSelectionGroup {
groupId: string;
kind: ToothSelectionKind;
teeth: FdiToothId[];
}
export interface LabCaseSendInfo {
@@ -79,6 +89,7 @@ export interface PastTreatmentDetail {
clientId: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
toothSelectionGroups?: ToothSelectionGroup[];
notes?: string | null;
attachmentMetas?: TreatmentAttachmentMeta[];
labCaseId?: string | null;
@@ -102,11 +113,13 @@ export interface PastLabCase {
clientId: string;
treatmentType: string;
teeth: FdiToothId[];
toothSelectionGroups?: ToothSelectionGroup[];
} | null;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
}>;
sends?: LabCaseSendInfo[];
dueDate?: string | null;
@@ -136,6 +149,7 @@ export interface TreatmentDetailDraft {
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string;
attachmentMetas: TreatmentAttachmentMeta[];
labCaseId?: string | null;
@@ -166,6 +180,7 @@ export type SavedTreatmentDetailPayload = {
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
toothSelectionGroups?: ToothSelectionGroup[];
comment: string;
attachmentIds: string[];
};
@@ -182,6 +197,7 @@ export interface SaveLabCasePayload {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
}>;
attachmentIds?: string[];
dueDate?: string | null;
@@ -204,6 +220,7 @@ export interface LabCaseResponse {
clientId: string;
treatmentType: string;
teeth: string[];
toothSelectionGroups?: ToothSelectionGroup[];
} | null;
sends: LabCaseSendInfo[];
dueDate: string | null;