improvement: treatments UI/UX updated again to minimize clicking and scrolling.
This commit is contained in:
15
frontend/src/components/treatment/detailChipLabel.ts
Normal file
15
frontend/src/components/treatment/detailChipLabel.ts
Normal 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}`;
|
||||
}
|
||||
73
frontend/src/components/treatment/labDispatchDefaults.ts
Normal file
73
frontend/src/components/treatment/labDispatchDefaults.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user