improvement: treatments UI/UX updated again to minimize clicking and scrolling.

This commit is contained in:
2026-08-19 23:59:37 +03:30
parent 29c639f5a1
commit d2f07c0ed3
33 changed files with 1651 additions and 744 deletions

View File

@@ -0,0 +1,15 @@
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
export function formatDetailChipLabel(
detail: { treatmentType: string; teeth: readonly string[] },
catalog: TreatmentCatalogEntry[],
fallback: string,
): string {
if (!isDetailTypeSelected(detail)) return fallback;
const typeLabel = treatmentTypeLabelFromCatalog(detail.treatmentType, catalog);
if (detail.teeth.length === 0) return typeLabel;
const teeth = [...detail.teeth].sort().join(', ');
return `${typeLabel} ${teeth}`;
}

View File

@@ -0,0 +1,73 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
export type LabDispatchDefaults = {
lastLabId: string | null;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
}
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
return {
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
};
} catch {
return EMPTY;
}
}
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
} catch {
// Ignore quota / private-mode failures.
}
}
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined,
labId: string,
prosthesisTypeCode: string,
): void {
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, {
...current,
lastLabId: labId,
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
}

View File

@@ -44,15 +44,39 @@ export function isDetailTypeSelected(detail: Pick<DetailLike, 'treatmentType'>):
}
export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean {
return isBlankTreatmentDetail(detail);
}
/** Strip-card delete: no type, teeth, notes, or attachments — including an empty list. */
export function isBlankTreatmentDetail(detail: {
treatmentType?: string | null;
teeth?: readonly string[] | null;
comment?: string | null;
notes?: string | null;
attachmentMetas?: readonly unknown[] | null;
}): boolean {
return (
!isDetailTypeSelected(detail) &&
detail.teeth.length === 0 &&
!detail.comment.trim()
!detail.treatmentType?.trim() &&
(detail.teeth?.length ?? 0) === 0 &&
!`${detail.comment ?? ''}${detail.notes ?? ''}`.trim() &&
(detail.attachmentMetas?.length ?? 0) === 0
);
}
export function areUnscheduledDetailsStripDeletable(
details: readonly {
treatmentType?: string | null;
teeth?: readonly string[] | null;
comment?: string | null;
notes?: string | null;
attachmentMetas?: readonly unknown[] | null;
}[],
): boolean {
return details.every(isBlankTreatmentDetail);
}
export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean {
return details.length > 0 && details.every((d) => isDetailTypeSelected(d));
return details.every((d) => isDetailTypeSelected(d));
}
/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */

View File

@@ -219,9 +219,24 @@ export function AppointmentsPage() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</div>
<div className="flex min-w-0 justify-center">
<ScheduleDayPicker
compact
className="max-w-sm"
value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
</div>
<div className="flex min-w-0 items-center justify-end">
{loadingSchedule ? (
<p className="text-sm text-text-muted">{t('loadingSchedule')}</p>
) : null}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
@@ -248,16 +263,6 @@ export function AppointmentsPage() {
<div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
<p className="text-sm text-text-muted pb-2">{t('loadingSchedule')}</p>
)}
</div>
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}

View File

@@ -96,6 +96,7 @@ interface CaseCreatePanelProps {
canEdit: boolean;
onSaved: (detail: LabCaseDetail) => void;
onStarted: (detail: LabCaseDetail) => void;
onDeleted: () => void;
onError: (message: string) => void;
}
@@ -104,6 +105,7 @@ export function CaseCreatePanel({
canEdit,
onSaved,
onStarted,
onDeleted,
onError,
}: CaseCreatePanelProps) {
const t = useTranslations('cases');
@@ -136,11 +138,13 @@ export function CaseCreatePanel({
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const hydratedIdRef = useRef(labCase.id);
const skipSaveRef = useRef(true);
const startingRef = useRef(false);
const deletingRef = useRef(false);
const allowPersistWhileStartingRef = useRef(false);
const attachmentInputRef = useRef<HTMLInputElement>(null);
@@ -221,6 +225,9 @@ export function CaseCreatePanel({
);
const persist = useCallback(async () => {
if (deletingRef.current) {
return null;
}
if (startingRef.current && !allowPersistWhileStartingRef.current) {
return null;
}
@@ -251,7 +258,7 @@ export function CaseCreatePanel({
skipSaveRef.current = false;
return;
}
if (!canEdit || startingRef.current) return;
if (!canEdit || startingRef.current || deletingRef.current) return;
const timeout = setTimeout(() => {
void persistRef.current();
}, 500);
@@ -264,6 +271,22 @@ export function CaseCreatePanel({
);
}
async function handleDelete() {
if (!canEdit) return;
if (!window.confirm(t('confirmDeleteDraftCase'))) return;
deletingRef.current = true;
skipSaveRef.current = true;
setDeleting(true);
try {
await casesApi.deleteDraft(labCase.id);
onDeleted();
} catch (error: unknown) {
deletingRef.current = false;
setDeleting(false);
onError(getUserFacingError(error, tErrors, t('errorDeleteCase')));
}
}
async function handleStart() {
if (!canEdit) return;
setStarting(true);
@@ -311,7 +334,7 @@ export function CaseCreatePanel({
}
}
const disabled = !canEdit || starting;
const disabled = !canEdit || starting || deleting;
return (
<div className="space-y-4">
@@ -320,14 +343,24 @@ export function CaseCreatePanel({
<h2 className="text-lg font-semibold text-text-primary">{t('addCaseTitle')}</h2>
<p className="text-sm text-text-muted mt-0.5">{t('addCaseSubtitle')}</p>
</div>
<Button
type="button"
variant="primary"
disabled={disabled || saving}
onClick={() => void handleStart()}
>
{starting ? tCommon('loading') : t('startCase')}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="danger"
disabled={disabled || saving}
onClick={() => handleDelete()}
>
{tCommon('delete')}
</Button>
<Button
type="button"
variant="primary"
disabled={disabled || saving}
onClick={() => void handleStart()}
>
{starting ? tCommon('loading') : t('startCase')}
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">

View File

@@ -397,8 +397,17 @@ export function CasesPage() {
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
{canEdit ? (
<Button
type="button"
size="lg"
fullWidth
disabled={creatingCase}
onClick={() => void handleAddCase()}
>
{t('addCase')}
</Button>
) : null}
<SearchBar
embedded
value={search}
@@ -408,19 +417,6 @@ export function CasesPage() {
}}
placeholder={t('searchPlaceholder')}
/>
</div>
{canEdit ? (
<Button
variant="primary"
size="sm"
className="shrink-0"
disabled={creatingCase}
onClick={() => void handleAddCase()}
>
{t('addCase')}
</Button>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="space-y-1">
@@ -661,6 +657,25 @@ export function CasesPage() {
page,
});
}}
onDeleted={() => {
const remainingOnPage = cases.filter((item) => item.id !== selectedCase.id);
const nextPage = remainingOnPage.length === 0 && page > 1 ? page - 1 : page;
setSelectedCaseId(null);
setSelectedCase(null);
setMobileDetailOpen(false);
if (nextPage !== page) {
setPage(nextPage);
return;
}
void loadCases({
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page: nextPage,
});
}}
onError={toast.showError}
/>
) : (

View File

@@ -40,9 +40,6 @@ export function LabCaseProsthesisGroupsList({
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>
@@ -52,6 +49,9 @@ export function LabCaseProsthesisGroupsList({
{formatToothList(group.teeth)}
</span>
) : null}
{group.connected ? (
<ConnectedSelectionBadge className="ms-1 align-middle text-[9px] px-1 py-px" />
) : null}
</li>
))}
</ul>

View File

@@ -61,6 +61,20 @@ export function CalendarDaySelect({
const today = startOfLocalDay(new Date());
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
const resolvedLabel = label ?? t('defaultLabel');
const showLabelRow = showHeader && Boolean(resolvedLabel);
const todayToggle = showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null;
const labelText = isEmpty
? (emptyLabel ?? t('chooseDate'))
: formatAppPickerDateLabel(normalizedValue, locale);
@@ -121,22 +135,10 @@ export function CalendarDaySelect({
return (
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
{showHeader ? (
{showLabelRow ? (
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
{showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null}
{todayToggle}
</div>
) : null}
@@ -170,6 +172,7 @@ export function CalendarDaySelect({
<NextIcon className="h-4 w-4 icon-flat" />
</button>
) : null}
{!showLabelRow && todayToggle ? <div className="pe-1">{todayToggle}</div> : null}
</div>
{panelOpen && !disabled ? (

View File

@@ -8,6 +8,9 @@ interface ScheduleDayPickerProps {
label?: string;
/** Show a "Today" toggle that jumps to the current local day when enabled. Default true. */
showTodayToggle?: boolean;
/** One-line control (no “Schedule date” label); Today sits on the navigator. */
compact?: boolean;
className?: string;
}
/**
@@ -19,15 +22,18 @@ export function ScheduleDayPicker({
onChange,
label,
showTodayToggle = true,
compact = false,
className,
}: ScheduleDayPickerProps) {
return (
<CalendarDaySelect
value={value}
onChange={onChange}
label={label}
showHeader
label={compact ? '' : label}
showHeader={!compact}
showTodayToggle={showTodayToggle}
showNavArrows
className={className}
/>
);
}

View File

@@ -56,28 +56,31 @@ export function AppointmentsStrip({
return (
<Card className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
<h2 className="text-sm font-semibold text-text-primary truncate">{t('appointmentsTitle')}</h2>
</div>
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
{t('hideAppointments')}
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={selectedDay}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
{loading && (
<p className="text-sm text-text-muted pb-2">{t('loadingAppointments')}</p>
)}
<div className="flex min-w-0 justify-center">
<ScheduleDayPicker
compact
className="max-w-sm"
value={selectedDay}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
{loading ? (
<p className="text-sm text-text-muted">{t('loadingAppointments')}</p>
) : null}
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
{t('hideAppointments')}
</button>
</div>
</div>
<div className="space-y-3">

View File

@@ -48,8 +48,10 @@ export function DayStripCard({
text-start w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
`;
const showDelete = item.kind === 'unscheduled' && Boolean(canDelete && onDelete);
const useTypeBanner = Boolean(item.colorCode);
if (item.kind === 'unscheduled') {
if (item.kind === 'unscheduled' && !useTypeBanner) {
const selectedClass = selected
? 'border-primary bg-primary-soft'
: 'border-border/70 hover:border-border hover:bg-background-card/50';
@@ -57,7 +59,6 @@ export function DayStripCard({
const trashClass = selected
? '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';
const showDelete = Boolean(canDelete && onDelete);
return (
<div
@@ -98,16 +99,52 @@ export function DayStripCard({
? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]'
: 'hover:brightness-110';
if (item.kind === 'appointment') {
return (
<Card
as="button"
type="button"
onClick={onSelect}
padding="none"
style={bannerStyle}
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
>
<StripCardBody item={item} />
</Card>
);
}
return (
<Card
as="button"
type="button"
onClick={onSelect}
padding="none"
<div
style={bannerStyle}
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-sm)] border transition-shadow ${selectedClass} ${shellClass}`}
>
<StripCardBody item={item} />
</Card>
<button
type="button"
onClick={onSelect}
className="min-w-0 flex-1 text-start px-3 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
>
<StripCardBody item={item} />
</button>
{showDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete?.();
}}
aria-label={deleteAriaLabel}
title={deleteAriaLabel}
className="
shrink-0 inline-flex items-center justify-center border-s px-2 transition-colors
text-inherit border-black/20
hover:bg-red-500/15 hover:text-red-600
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
) : null}
</div>
);
}

View File

@@ -1,7 +1,14 @@
'use client';
import { useId, type CSSProperties, type ReactNode } from 'react';
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import {
getToothColumnWidthRem,
@@ -17,8 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px';
/** Fixed row cell height — must fit tallest scaled canine/root. */
const TOOTH_CELL_H = 'h-[7.25rem]';
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
@@ -47,6 +52,65 @@ function toothSize(fdi: FdiToothId): {
};
}
function ToothNumber({
fdi,
selected,
widthRem,
colorClass,
style,
interactive,
disabled,
onActivate,
}: {
fdi: FdiToothId;
selected: boolean;
widthRem: number;
colorClass: string;
style?: CSSProperties;
interactive: boolean;
disabled: boolean;
onActivate: (shiftKey: boolean) => void;
}) {
const className = `text-[10px] tabular-nums text-center leading-none ${colorClass}`;
const boxStyle: CSSProperties = { width: `${widthRem}rem`, ...style };
if (!interactive) {
return (
<span className={className} style={boxStyle}>
{fdi}
</span>
);
}
return (
<button
type="button"
disabled={disabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
onActivate(e.shiftKey);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onActivate(e.shiftKey);
}}
aria-pressed={selected}
className={`${className} bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
style={boxStyle}
>
{fdi}
</button>
);
}
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
/**
@@ -71,6 +135,8 @@ interface FdiToothChartProps {
headerControl?: ReactNode;
/** Compact card for embedded case detail panels. */
compact?: boolean;
/** Nested in the treatment editor — no extra card chrome. */
embedded?: boolean;
className?: string;
}
@@ -126,10 +192,13 @@ export function FdiToothChart({
toothColors,
headerControl,
compact = false,
embedded = false,
className = '',
}: FdiToothChartProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const uid = useId().replace(/:/g, '');
const [hintOpen, setHintOpen] = useState(false);
const archPeak = 8;
const interactive = !readOnly && Boolean(onToggle);
const linkInteractive = interactive && Boolean(onToggleLink);
@@ -293,6 +362,13 @@ export function FdiToothChart({
/>
);
/** Cover crown/root shift toward the numbers without overlapping neighbors. */
const hitOverflowPx = Math.abs(offsetY);
const activateTooth = (shiftKey: boolean) => {
if (!interactive || isDisabled) return;
onToggle?.(fdi, { shiftKey });
};
return (
<div
key={fdi}
@@ -302,42 +378,66 @@ export function FdiToothChart({
{!upper && realistic ? (
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
) : null}
<div className={`${TOOTH_CELL_H} flex ${alignItems} justify-center`}>
<div
className={`group relative box-content flex ${alignItems} justify-center`}
style={{
height: '7.25rem',
paddingBottom: upper ? hitOverflowPx : 0,
paddingTop: upper ? 0 : hitOverflowPx,
}}
>
{interactive ? (
<button
type="button"
disabled={isDisabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => {
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
onToggle?.(fdi, { shiftKey: e.shiftKey });
e.stopPropagation();
activateTooth(e.shiftKey);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
activateTooth(e.shiftKey);
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
absolute inset-y-[6%] z-10 rounded-[var(--radius-sm)] bg-transparent select-none touch-manipulation
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
style={{ left: '22%', right: '22%' }}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
/>
) : null}
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`pointer-events-none ${isDisabled ? 'opacity-50' : ''}`}
aria-hidden
>
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform duration-150
${isSel ? 'scale-105' : ''}
${
interactive && !isDisabled
? '[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-105'
: ''
}
`}
>
{glyph}
</div>
)}
</div>
</div>
{upper && realistic ? (
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
@@ -351,9 +451,11 @@ export function FdiToothChart({
</div>
);
const cardClass = compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const cardClass = embedded
? 'space-y-2'
: compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const chartBody = (
<>
@@ -368,44 +470,38 @@ export function FdiToothChart({
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
style={{
width: `${size.columnWidthRem}rem`,
...(numberStyle(fdi, isSel) ?? {}),
}}
>
{fdi}
</span>
);
})}
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
<ToothNumber
key={`u-${fdi}`}
fdi={fdi}
selected={selected.has(fdi)}
widthRem={toothSizeClass(fdi).columnWidthRem}
colorClass={numberColorClass(fdi, selected.has(fdi))}
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
/>
))}
</div>
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
<div className="pt-0.5">
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP}`}>
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
style={{
width: `${size.columnWidthRem}rem`,
...(numberStyle(fdi, isSel) ?? {}),
}}
>
{fdi}
</span>
);
})}
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => (
<ToothNumber
key={`l-${fdi}`}
fdi={fdi}
selected={selected.has(fdi)}
widthRem={toothSizeClass(fdi).columnWidthRem}
colorClass={numberColorClass(fdi, selected.has(fdi))}
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
/>
))}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
@@ -421,31 +517,63 @@ export function FdiToothChart({
return (
<div className={`${cardClass} ${className}`}>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
<div className="flex min-w-0 items-center gap-1">
<h3 className="text-sm font-semibold text-text-primary">
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
</h3>
{!compact && (
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
)}
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
{headerControl}
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
{!compact && !embedded ? (
<button
type="button"
onClick={() => setHintOpen(true)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-muted hover:bg-background-card hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('toothChartHelpAria')}
>
<Info className="h-4 w-4" aria-hidden />
</button>
) : null}
</div>
<div className="flex justify-center">{headerControl}</div>
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
{scale !== 1 ? (
<div style={{ zoom: scale }} className="origin-top-left w-fit">
<div style={{ zoom: scale }} className="mx-auto w-fit origin-top">
{chartBody}
</div>
) : (
chartBody
)}
{hintOpen ? (
<ResponsiveDialogOverlay onBackdropClick={() => setHintOpen(false)}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-chart-hint-title`}
maxWidthClass="sm:max-w-md"
>
<div className="flex items-start justify-between gap-2 mb-3">
<h4
id={`${uid}-chart-hint-title`}
className="text-sm font-semibold text-text-primary pe-2"
>
{t('toothChartHelpAria')}
</h4>
<DialogCloseButton onClick={() => setHintOpen(false)} />
</div>
<p className="text-sm text-text-secondary leading-relaxed">{t('toothChartHint')}</p>
<div className="mt-4">
<Button type="button" variant="outline" fullWidth onClick={() => setHintOpen(false)}>
{tCommon('close')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
) : null}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,7 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -29,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -98,12 +104,27 @@ function isProsthesisMapComplete(
);
}
function toothProsthesisForRows(
rows: ProsthesisGroupRow[],
prosthesisTypeCode: string,
): LabCaseDraft['toothProsthesis'] {
return rows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
);
}
export function LabCasesDispatchPanel({
details,
activeDetailId,
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -148,8 +170,6 @@ export function LabCasesDispatchPanel({
labCaseForActiveDetail ??
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -190,16 +210,6 @@ export function LabCasesDispatchPanel({
setPendingComment('');
}, [activeLabCase?.clientId]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function detailSummary(d: TreatmentDetailDraft) {
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
@@ -207,6 +217,58 @@ export function LabCasesDispatchPanel({
);
}
useEffect(() => {
if (!activeLabCase || sent || !activeDetail) return;
const ids = activeDetail.attachmentMetas.map((a) => a.id);
const missing = ids.filter((id) => !activeLabCase.attachmentIds.includes(id));
if (missing.length === 0) return;
updateActiveLabCase({ attachmentIds: [...activeLabCase.attachmentIds, ...missing] });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
if (!activeLabCase) return;
const toothSet = new Set(row.teeth);
@@ -225,19 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
})),
);
updateActiveLabCase({ toothProsthesis: next });
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -249,17 +317,17 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
// Comments/progress belong to the shipment context, even when the treatment is opened from history.
// Do not block commenting just because the treatment editor is read-only.
const canPostComments = canEdit && !caseFullyComplete;
const canShowComments = Boolean(activeLabCase?.id);
const commentsDeferSubmit = Boolean(!sent);
@@ -314,67 +382,8 @@ export function LabCasesDispatchPanel({
);
}
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
);
}
const selectionGroups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.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,
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">
{rows.map((g) => (
<p
key={g.groupId}
className="text-[13px]"
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>
))}
</div>
);
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -383,36 +392,16 @@ export function LabCasesDispatchPanel({
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
{typeLabel} · {teethLabel}
</p>
</div>
{detailAlreadyInShipment ? renderDueDateField() : null}
{activeLabCase ? renderDueDateField() : null}
</div>
{activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
{sent ? (
<>
{renderIncludedDetailSummary()}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
@@ -431,30 +420,6 @@ export function LabCasesDispatchPanel({
}}
orgs={orgs}
/>
</>
) : (
<>
{renderIncludedDetailSummary()}
{!sent && activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
@@ -469,15 +434,14 @@ export function LabCasesDispatchPanel({
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
</>
) : (
<>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<LinkedOrganizationSearchCombobox
@@ -499,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => onRecentOrganizationPick(o.id)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -514,7 +481,7 @@ export function LabCasesDispatchPanel({
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
{flatToothCount > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
@@ -593,6 +560,48 @@ export function LabCasesDispatchPanel({
</div>
) : null}
{activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-1">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
@@ -606,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>

View File

@@ -176,7 +176,7 @@ export const ToothGlyph = memo(function ToothGlyph({
<svg
viewBox={realistic.viewBox}
preserveAspectRatio={preserveAspectRatio}
className={`${className} shrink-0`}
className={`${className} shrink-0 pointer-events-none`}
style={svgStyle}
aria-hidden
>
@@ -203,7 +203,7 @@ export const ToothGlyph = memo(function ToothGlyph({
if (!model) {
return (
<svg viewBox="0 0 36 78" className={`${className} shrink-0`} style={style} aria-hidden>
<svg viewBox="0 0 36 78" className={`${className} shrink-0 pointer-events-none`} style={style} aria-hidden>
<text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
?
</text>
@@ -228,7 +228,7 @@ export const ToothGlyph = memo(function ToothGlyph({
return (
<svg
viewBox="0 0 36 78"
className={`${className} shrink-0`}
className={`${className} shrink-0 pointer-events-none`}
style={svgStyle}
aria-hidden
>

View File

@@ -0,0 +1,269 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { FileText, Paperclip } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
/** Match Dropdown control height (`py-2.5 sm:py-2` + text-base/sm + border). */
const CONTROL_H = 'h-[2.875rem] sm:h-9';
const THUMB_CLASS =
'relative aspect-square h-full shrink-0 overflow-hidden rounded-[var(--radius-sm)] bg-background-secondary';
type AttachmentKind = 'image' | 'pdf' | 'file';
const ATTACHMENT_KIND_ORDER: AttachmentKind[] = ['image', 'pdf', 'file'];
function attachmentKind(mimeType: string): AttachmentKind {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'application/pdf') return 'pdf';
return 'file';
}
function groupAttachmentsByKind(attachments: TreatmentAttachmentMeta[]) {
const byKind: Record<AttachmentKind, TreatmentAttachmentMeta[]> = {
image: [],
pdf: [],
file: [],
};
for (const item of attachments) {
byKind[attachmentKind(item.mimeType)].push(item);
}
return ATTACHMENT_KIND_ORDER.filter((kind) => byKind[kind].length > 0).map((kind) => ({
kind,
items: byKind[kind],
}));
}
function AttachmentGroup({ children }: { children: ReactNode }) {
return (
<div className="flex shrink-0 items-stretch gap-2 border-s border-border/60 px-2">
{children}
</div>
);
}
type TreatmentDetailAttachmentsStripProps = {
attachments: TreatmentAttachmentMeta[];
disabled: boolean;
uploadBusy: boolean;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void;
};
export function TreatmentDetailAttachmentsStrip({
attachments,
disabled,
uploadBusy,
onUploadFiles,
onRemoveAttachment,
}: TreatmentDetailAttachmentsStripProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const inputRef = useRef<HTMLInputElement>(null);
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
const [previewId, setPreviewId] = useState<string | null>(null);
const preview = attachments.find((item) => item.id === previewId) ?? null;
const attachmentGroups = groupAttachmentsByKind(attachments);
async function handleFiles(list: FileList | null) {
if (!list?.length || disabled || uploadBusy) return;
setUploadPercent(0);
try {
await onUploadFiles(Array.from(list), (percent) => setUploadPercent(percent));
} finally {
setUploadPercent(null);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="min-w-0">
<p className="block text-sm font-medium text-text-secondary mb-1">{t('attachments')}</p>
<div
className={`
flex min-w-0 items-stretch overflow-hidden rounded-[var(--radius-md)] border border-border
bg-background-card ${CONTROL_H}
${disabled ? 'opacity-50' : ''}
`}
>
<input
ref={inputRef}
type="file"
multiple
disabled={disabled || uploadBusy}
onChange={(e) => void handleFiles(e.target.files)}
className="sr-only"
aria-label={t('attachFiles')}
/>
<button
type="button"
disabled={disabled || uploadBusy}
onClick={() => inputRef.current?.click()}
title={t('chooseFiles')}
aria-label={t('chooseFiles')}
className={`
inline-flex aspect-square h-full shrink-0 items-center justify-center
text-text-secondary hover:bg-background-card/80 hover:text-text-primary
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/35
disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-inherit
`}
>
<Paperclip className="h-4 w-4" aria-hidden />
</button>
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto py-1 pe-0.5">
{uploadBusy ? (
<AttachmentGroup>
<div
className={THUMB_CLASS}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={uploadPercent ?? 0}
aria-label={t('saveStatusSaving')}
>
<div className="absolute inset-0 flex items-center justify-center p-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-border">
<div
className={`h-full rounded-full bg-primary ${uploadPercent ? 'transition-[width]' : 'animate-pulse'}`}
style={{ width: `${Math.max(uploadPercent ?? 8, 8)}%` }}
/>
</div>
</div>
</div>
</AttachmentGroup>
) : null}
{attachmentGroups.map((group) => (
<AttachmentGroup key={group.kind}>
{group.items.map((file) => (
<button
key={file.id}
type="button"
title={file.fileName}
onClick={() => setPreviewId(file.id)}
className={`${THUMB_CLASS} focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/35`}
>
<AttachmentThumb attachment={file} />
</button>
))}
</AttachmentGroup>
))}
</div>
</div>
{preview ? (
<ResponsiveDialogOverlay onBackdropClick={() => setPreviewId(null)}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
aria-labelledby="treatment-attachment-preview-title"
maxWidthClass="sm:max-w-lg"
>
<div className="mb-3 flex items-start justify-between gap-2">
<h4
id="treatment-attachment-preview-title"
className="min-w-0 truncate text-sm font-semibold text-text-primary pe-2"
title={preview.fileName}
>
{preview.fileName}
</h4>
<DialogCloseButton onClick={() => setPreviewId(null)} />
</div>
<AttachmentPreviewBody attachment={preview} />
<div className="mt-4 flex flex-wrap justify-end gap-2">
{onRemoveAttachment && !disabled ? (
<Button
type="button"
variant="danger"
onClick={() => {
onRemoveAttachment(preview.id);
setPreviewId(null);
}}
>
{tCommon('delete')}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => setPreviewId(null)}>
{tCommon('close')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
) : null}
</div>
);
}
function AttachmentThumb({ attachment }: { attachment: TreatmentAttachmentMeta }) {
const { url, failed } = useAttachmentUrl(attachment.id);
if (url && attachment.mimeType.startsWith('image/') && !failed) {
return <img src={url} alt="" className="h-full w-full object-cover" />;
}
return (
<span className="flex h-full w-full items-center justify-center text-text-muted">
<FileText className="h-4 w-4" aria-hidden />
</span>
);
}
function AttachmentPreviewBody({ attachment }: { attachment: TreatmentAttachmentMeta }) {
const t = useTranslations('treatment');
const { url, failed } = useAttachmentUrl(attachment.id);
if (failed || !url) {
return <p className="text-sm text-text-muted">{t('attachmentPreviewUnavailable')}</p>;
}
if (attachment.mimeType.startsWith('image/')) {
return (
<img
src={url}
alt={attachment.fileName}
className="max-h-[min(24rem,60vh)] w-full rounded-[var(--radius-md)] object-contain bg-background-secondary"
/>
);
}
if (attachment.mimeType === 'application/pdf') {
return (
<iframe
src={url}
title={attachment.fileName}
className="h-[min(24rem,60vh)] w-full rounded-[var(--radius-md)] border-0 bg-background-secondary"
/>
);
}
return <p className="text-sm text-text-muted">{attachment.fileName}</p>;
}
function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await treatmentsApi.getAttachmentFileBlob(attachmentId);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId]);
return { url, failed };
}

View File

@@ -1,21 +1,17 @@
'use client';
import { useRef } from 'react';
import { useEffect, useRef, type ReactNode, type RefObject } 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 { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
import {
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
@@ -34,11 +30,21 @@ interface TreatmentDetailsEditorProps {
uploadBusy: boolean;
onAddDetail: () => void;
onRemoveDetail?: (detailClientId: string) => void;
onUploadFiles: (files: FileList | null) => void;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void;
/** Detail chips + Add detail (default true). */
showChrome?: boolean;
/** Type / notes / attachments fields (default true). */
showFields?: boolean;
/** FDI chart (or other) rendered beside notes on wide screens. */
chart?: ReactNode;
/** Shown below chrome (e.g. prosthesis wizard). */
stepper?: ReactNode;
/** Shown below type + chart + notes (e.g. Continue to lab). */
footer?: ReactNode;
/** Dim the chart until a treatment type is chosen. */
chartLocked?: boolean;
chartLockMessage?: string;
}
export function TreatmentDetailsEditor({
@@ -56,32 +62,44 @@ export function TreatmentDetailsEditor({
onAddDetail,
onRemoveDetail,
onUploadFiles,
onRemoveAttachment,
showChrome = true,
showFields = true,
chart,
stepper,
footer,
chartLocked = false,
chartLockMessage,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
if (!activeDetail) return null;
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
const showPendingLabHint =
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
activeDetail,
labDependentCodes,
const locked = Boolean(activeDetail && isDetailLocked(activeDetail));
const readOnly = !activeDetail || disabled || locked;
const showMissingTeethLabBlock = Boolean(
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
);
if (!showChrome && !showFields) return null;
if (!showChrome && !showFields && !stepper) return null;
const treatmentTypeTextColor =
activeDetail && isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
function setActiveType(nextType: string) {
onDetailsChange(
details.map((d) =>
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
),
);
}
return (
<div className="surface-card p-3 sm:p-4 space-y-4">
@@ -90,7 +108,6 @@ export function TreatmentDetailsEditor({
<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"
@@ -108,16 +125,20 @@ export function TreatmentDetailsEditor({
{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;
const showRemoveAction = Boolean(onRemoveDetail);
const removeDisabled = !canEdit || disabled || detailLocked || uploadBusy;
const chipLabel = formatDetailChipLabel(
d,
treatmentCatalog,
t('detailLabel', { n: idx + 1 }),
);
const showLabStatus =
isDetailTypeSelected(d) && labDependentCodes.has(d.treatmentType);
return (
<div
key={d.clientId}
className={`
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border max-w-full
${
isActive
? 'border-primary bg-primary-soft'
@@ -128,14 +149,25 @@ export function TreatmentDetailsEditor({
<button
type="button"
onClick={() => onActiveDetailChange(d.clientId)}
title={chipLabel}
className={`
px-3 py-1.5 text-sm transition-colors
inline-flex max-w-[20rem] items-center gap-1.5 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')}` : ''}
<span className="truncate">{chipLabel}</span>
{showLabStatus ? (
<span
className={`shrink-0 ${
detailLocked
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-400'
}`}
>
· {detailLocked ? t('detailSentBadge') : t('detailUnsentBadge')}
</span>
) : null}
</button>
{showRemoveAction ? (
<button
@@ -169,114 +201,112 @@ export function TreatmentDetailsEditor({
</>
) : 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>
)}
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<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,
),
);
}}
onChange={(e) => setActiveType(e.target.value)}
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',
}}
>
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
{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={2}
<TreatmentDetailAttachmentsStrip
attachments={activeDetail.attachmentMetas}
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-[60px]"
uploadBusy={uploadBusy}
onUploadFiles={onUploadFiles}
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
/>
</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>
{chart ? (
<div className="relative min-w-0 w-full">
{chartLocked ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-md)] bg-background-card/70 px-4">
<p className="text-sm text-text-secondary text-center">{chartLockMessage}</p>
</div>
) : null}
<div className={chartLocked ? 'pointer-events-none opacity-40' : undefined}>{chart}</div>
</div>
) : null}
<NotesField
textareaRef={notesRef}
value={activeDetail.comment}
disabled={readOnly}
label={t('comments')}
placeholder={t('commentsPlaceholder')}
onChange={(v) => {
onDetailsChange(
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
);
}}
/>
{canEdit && saveStatus !== 'idle' && (
<p className={`text-xs ${autosaveStatusClass(saveStatus)}`} role="status" aria-live="polite">
{saveStatus === 'dirty' && t('unsavedChanges')}
{saveStatus === 'saving' && t('saveStatusSaving')}
{saveStatus === 'saved' && t('saveStatusSaved')}
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : null}
{showFields && canEdit && saveStatus !== 'idle' && (
<p
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
role="status"
aria-live="polite"
>
{saveStatus === 'dirty' && t('unsavedChanges')}
{saveStatus === 'saving' && t('saveStatusSaving')}
{saveStatus === 'saved' && t('saveStatusSaved')}
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
</div>
);
}
function NotesField({
textareaRef,
value,
disabled,
label,
placeholder,
onChange,
}: {
textareaRef: RefObject<HTMLTextAreaElement | null>;
value: string;
disabled: boolean;
label: string;
placeholder: string;
onChange: (value: string) => void;
}) {
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = '0px';
el.style.height = `${el.scrollHeight}px`;
}, [textareaRef, value]);
return (
<label className="block text-xs font-medium text-text-secondary min-w-0">
{label}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={1}
disabled={disabled}
className="mt-1.5 w-full min-h-[2.875rem] sm:min-h-9 resize-none overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-3 py-2.5 sm:py-2 text-base sm:text-sm leading-tight text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35"
/>
</label>
);
}

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import {
@@ -39,8 +38,10 @@ import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
areUnscheduledDetailsStripDeletable,
} from '@/components/treatment/treatmentDetailRules';
import {
applyShiftRange,
@@ -56,6 +57,7 @@ import {
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -77,15 +79,14 @@ import type {
PastLabCase,
PastTreatment,
PastTreatmentCase,
PastTreatmentDetail,
TreatmentAppointment,
TreatmentDetailDraft,
} from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
type WorkspaceMode = 'live' | 'historical';
type EntryStep = 'teeth' | 'content' | 'lab';
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
type EntryStep = 'treatment' | 'lab';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
@@ -228,6 +229,22 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
};
}
function draftToPastDetail(d: TreatmentDetailDraft): PastTreatmentDetail {
return {
id: d.id ?? d.clientId,
clientId: d.clientId,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
notes: d.comment,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
};
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
return {
clientId: lc.clientId,
@@ -419,7 +436,7 @@ export function TreatmentWorkspace({
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
useEffect(() => {
@@ -439,7 +456,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 [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
@@ -560,20 +577,12 @@ export function TreatmentWorkspace({
[details, labDependentCodes],
);
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
/** Lab send sheet only for prosthesis (lab-dependent) 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(
@@ -582,6 +591,30 @@ export function TreatmentWorkspace({
[activeDetail, labDependentCodes],
);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
useEffect(() => {
if (!selectedStandaloneId || draftHydratingRef.current) return;
const nextDetails = details.map(draftToPastDetail);
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
areUnscheduledDetailsStripDeletable(nextDetails)
) {
return prev;
}
return prev.map((row) =>
row.id === selectedStandaloneId ? { ...row, details: nextDetails } : row,
);
});
}, [selectedStandaloneId, details]);
const dayStripItems = useMemo<DayStripItem[]>(() => {
const timed: DayStripItem[] = appointments.map((a) => ({
kind: 'appointment',
@@ -596,7 +629,11 @@ export function TreatmentWorkspace({
}));
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
const isWalkIn = Boolean(tr.patient?.isWalkIn);
const colorCode = tr.details[0]?.treatmentType || 'visit';
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return {
kind: 'unscheduled' as const,
id: tr.id,
@@ -607,11 +644,19 @@ export function TreatmentWorkspace({
colorCode,
timeLabel: null,
subtitle: t('noAppointment'),
canDelete: tr.details.length === 0,
canDelete: areUnscheduledDetailsStripDeletable(sourceDetails),
};
});
return [...timed, ...unscheduled];
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
}, [
appointments,
standaloneTreatments,
selectedStandaloneId,
details,
locale,
treatmentCatalog,
t,
]);
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
@@ -681,16 +726,21 @@ export function TreatmentWorkspace({
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const hydrateFromTreatment = useCallback((
treatment: PastTreatment,
options?: { seedBlankIfEmpty?: boolean },
) => {
const mapped = treatment.details.map(mapDetailFromApi);
const nextDetails =
mapped.length > 0
? mapped
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
: options?.seedBlankIfEmpty
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
const stillExists = nextDetails.some((d) => d.clientId === prev);
return stillExists ? prev : nextDetails[0].clientId;
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(nextDetails));
const mappedLabCases = withoutEmptyLabCaseDrafts(
@@ -734,23 +784,34 @@ export function TreatmentWorkspace({
return colors;
}, [details, treatmentCatalog]);
const wholePlanLinkedEdges = useMemo(() => {
const edges = new Set<string>();
for (const detail of details) {
for (const key of linkedEdgesFromGroups(detail.toothSelectionGroups ?? [])) {
edges.add(key);
}
}
return edges;
}, [details]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'teeth');
setEntryStep(pending ?? 'treatment');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
useEffect(() => {
if (entryStep === 'lab' && !showLabWizardStep) {
setEntryStep('content');
setEntryStep('treatment');
}
}, [entryStep, showLabWizardStep]);
@@ -849,6 +910,12 @@ export function TreatmentWorkspace({
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
);
}
} catch (error: unknown) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
@@ -858,7 +925,7 @@ export function TreatmentWorkspace({
return () => {
cancelled = true;
};
}, [showError, t]);
}, [showError, t, currentOrganization?.id]);
useEffect(() => {
if (!activePatientId) {
@@ -915,6 +982,19 @@ export function TreatmentWorkspace({
setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId));
}, []);
const activeLabCaseSummary = useMemo(() => {
if (activeSentLabCaseId) {
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
}
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
);
}, []);
useEffect(() => {
void refreshUnreadLabCases();
}, [refreshUnreadLabCases]);
@@ -1003,6 +1083,10 @@ export function TreatmentWorkspace({
return stillExists ? prev : mapped[0].clientId;
});
setSavedSnapshot(serializeDetails(mapped));
} else if (response.data) {
setDetails([]);
setActiveDetailId('');
setSavedSnapshot(serializeDetails([]));
} else {
const first = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
@@ -1101,7 +1185,7 @@ export function TreatmentWorkspace({
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
@@ -1110,7 +1194,7 @@ export function TreatmentWorkspace({
setDetails(merged);
setActiveDetailId((prev) => {
const stillExists = merged.some((d) => d.clientId === prev);
return stillExists ? prev : merged[0]?.clientId ?? prev;
return stillExists ? prev : (merged[0]?.clientId ?? '');
});
// Keep dirty so the queued autosave persists the newer local state.
}
@@ -1229,6 +1313,7 @@ export function TreatmentWorkspace({
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
@@ -1244,6 +1329,7 @@ export function TreatmentWorkspace({
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
@@ -1281,7 +1367,7 @@ export function TreatmentWorkspace({
);
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
hydrateFromTreatment(created.data);
hydrateFromTreatment(created.data, { seedBlankIfEmpty: true });
draftHydratingRef.current = false;
if (created.data.patientId && !created.data.patient?.isWalkIn) {
await refreshHistory(created.data.patientId);
@@ -1313,15 +1399,28 @@ export function TreatmentWorkspace({
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
const wasSelected = selectedStandaloneId === item.id;
if (wasSelected) {
draftHydratingRef.current = true;
}
try {
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Chip-delete / cleared type can look empty in the strip before autosave
// finishes. Persist [] first so DELETE /treatments/:id (empty-only) succeeds.
await treatmentsApi.saveDraftByTreatment(item.id, { details: [] });
await treatmentsApi.deleteStandalone(item.id);
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
if (selectedStandaloneId === item.id) {
if (wasSelected) {
resetToLiveContext();
setSelectedStandaloneId(null);
setSelectedAppointmentId(null);
}
} catch (error: unknown) {
if (wasSelected) {
draftHydratingRef.current = false;
}
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
}
},
@@ -1522,19 +1621,6 @@ export function TreatmentWorkspace({
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const activeLabCaseSummary = useMemo(() => {
if (activeSentLabCaseId) {
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
}
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
);
}, []);
const handleSelectPatientLabCase = useCallback(
(item: PatientLabCaseSummary) => {
void (async () => {
@@ -1635,7 +1721,11 @@ export function TreatmentWorkspace({
}, [activeDetailId, patientLabCases]);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
async (
detailClientId: string,
files: FileList | File[],
onProgress?: (percent: number) => void,
) => {
if (!canEditTreatmentForDay || !hasLiveContext) return;
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
if (target && isDetailLocked(target)) return;
@@ -1645,15 +1735,17 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(detailClientId);
try {
const uploaded = selectedAppointment
? await treatmentsApi.uploadCaseAttachments(
? await treatmentsApi.uploadDetailAttachments(
selectedAppointment.id,
detailClientId,
list,
onProgress,
)
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
selectedStandalone!.id,
detailClientId,
list,
onProgress,
);
setDetails((prev) =>
prev.map((d) =>
@@ -1790,20 +1882,20 @@ export function TreatmentWorkspace({
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 (!target || isDetailLocked(target)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
activeDetailId === detailClientId
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
nextDetails[0]?.clientId)
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? '')
: activeDetailId;
// Sync ref before any persist triggered by lab-case cleanup (same tick).
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (nextActive) setActiveDetailId(nextActive);
setActiveDetailId(nextActive);
if (nextDetails.length === 0) setEntryStep('treatment');
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
handleLabCasesChange(
@@ -1820,6 +1912,7 @@ export function TreatmentWorkspace({
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
setEntryStep,
t,
],
);
@@ -1866,9 +1959,15 @@ export function TreatmentWorkspace({
return;
}
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
setLabCaseDrafts(updatedLabCases);
@@ -1883,14 +1982,17 @@ export function TreatmentWorkspace({
}, [
activeDetailId,
canEditTreatmentForDay,
currentOrganization?.id,
details,
labCaseDrafts,
labDependentCodes,
orgs,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
tErrors,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
@@ -1964,7 +2066,7 @@ export function TreatmentWorkspace({
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
@@ -2004,6 +2106,7 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberLastLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
@@ -2030,6 +2133,7 @@ export function TreatmentWorkspace({
showError,
t,
tErrors,
currentOrganization?.id,
],
);
@@ -2046,9 +2150,9 @@ export function TreatmentWorkspace({
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
</p>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
@@ -2132,16 +2236,6 @@ export function TreatmentWorkspace({
)}
</div>
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
<TreatmentPreviewCard
treatment={currentDraftPreview}
heading={t('previewCurrentDraft')}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
/>
) : null}
{isBrowsing && previewTreatment ? (
<>
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
@@ -2257,218 +2351,242 @@ export function TreatmentWorkspace({
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('teeth');
setEntryStep('treatment');
}}
onRemoveDetail={handleRemoveDetail}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
onUploadFiles={(files, onProgress) =>
uploadForDetail(activeDetailId, files, onProgress)
}
onRemoveAttachment={(attachmentId) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId === activeDetailId
? {
...d,
attachmentMetas: d.attachmentMetas.filter((a) => a.id !== attachmentId),
}
: d,
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.detailClientId === activeDetailId && !lc.sentAt
? {
...lc,
attachmentIds: lc.attachmentIds.filter((id) => id !== attachmentId),
}
: lc,
),
);
}}
showChrome
showFields={false}
showFields={entryStep === 'treatment'}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
chartLockMessage={t('selectTypeBeforeTeeth')}
chart={
entryStep === 'treatment' ? (
<FdiToothChart
className="w-full"
selected={chartSelectedTeeth}
linkedEdges={chartLinkedEdges}
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
<button
type="button"
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
aria-pressed={showWholeTreatmentPlan}
className={`
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
showWholeTreatmentPlan
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('toothChartWholePlan')}
</button>
}
onToggle={(fdi, event) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
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!,
),
};
}),
);
}}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
disabled={
!canEditTreatmentForDay ||
activeLocked ||
!activeTypeSelected
}
/>
) : undefined
}
stepper={
showLabWizardStep ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={[
{ id: 'treatment', label: t('entryStepTreatment') },
{ id: 'lab', label: t('entryStepLab') },
]}
currentStepId={entryStep}
onStepChange={(stepId) => {
const next = stepId as EntryStep;
if (next === 'lab' && canEditTreatmentForDay) {
void persistDraft({ force: true })
.then(() => setEntryStep('lab'))
.catch((error: unknown) => {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
});
return;
}
setEntryStep(next);
}}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'treatment'}
onClick={() => setEntryStep('treatment')}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={entryStep === 'lab'}
onClick={async () => {
if (canEditTreatmentForDay) {
try {
await persistDraft({ force: true });
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
return;
}
}
setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
) : undefined
}
/>
<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}
linkedEdges={showWholeTreatmentPlan ? undefined : linkedToothEdges}
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!,
),
};
}),
);
}}
onToggleLink={(a, b) => {
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);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
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}>
<div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
@@ -2477,6 +2595,7 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}

View File

@@ -61,6 +61,11 @@ export const casesApi = {
return response.data;
},
deleteDraft: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/cases/${id}`);
return response.data;
},
uploadLineAttachments: async (
caseId: string,
lineClientId: string,

View File

@@ -95,6 +95,7 @@ export const treatmentsApi = {
treatmentId: string,
detailClientId: string,
files: File[],
onUploadProgress?: (percent: number) => void,
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
@@ -103,7 +104,16 @@ export const treatmentsApi = {
const response = await apiClient.post(
`/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
onUploadProgress: onUploadProgress
? (event) => {
if (!event.total) return;
onUploadProgress(Math.round((event.loaded / event.total) * 100));
}
: undefined,
},
);
return response.data;
},
@@ -131,6 +141,7 @@ export const treatmentsApi = {
appointmentId: string,
detailClientId: string,
files: File[],
onUploadProgress?: (percent: number) => void,
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
@@ -139,7 +150,16 @@ export const treatmentsApi = {
const response = await apiClient.post(
`/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
onUploadProgress: onUploadProgress
? (event) => {
if (!event.total) return;
onUploadProgress(Math.round((event.loaded / event.total) * 100));
}
: undefined,
},
);
return response.data;
},