- {appointments.length === 0 && (
-
{t('emptyDay')}
- )}
- {appointments.map((a) => {
- const sel = a.id === selectedAppointmentId;
- const timeLabel = formatAppTimeRange(a.startAt, a.endAt, locale);
- const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
- const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
- return (
-
onSelectAppointment(a.id)}
- padding="none"
- style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
- className={`
- text-start rounded-[var(--radius-sm)] px-3 py-2 w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
- focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
- ${sel ? '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'}
- `}
- >
- {timeLabel}
-
- {a.patientFirstName} {a.patientLastName}
-
- {purposeLabel}
-
- );
- })}
+
+
+ {timed.length === 0 && unscheduled.length === 0 && (
+
{t('emptyDay')}
+ )}
+ {timed.map((item) => (
+
onSelectItem(item)}
+ />
+ ))}
+
+ {unscheduled.length > 0 ? (
+
+
{t('unscheduledHeading')}
+
+ {unscheduled.map((item) => (
+ onSelectItem(item)}
+ canDelete={item.canDelete}
+ onDelete={
+ item.canDelete && onDeleteUnscheduled
+ ? () => onDeleteUnscheduled(item)
+ : undefined
+ }
+ deleteAriaLabel={t('deleteEmptyTreatment')}
+ />
+ ))}
+
+
+ ) : null}
);
diff --git a/frontend/src/components/ui/treatment/DayStripCard.tsx b/frontend/src/components/ui/treatment/DayStripCard.tsx
new file mode 100644
index 0000000..741add7
--- /dev/null
+++ b/frontend/src/components/ui/treatment/DayStripCard.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { Trash2 } from 'lucide-react';
+import { Card } from '@/components/ui/shared/Card';
+import { treatmentTypeBannerStyle } from '@/components/shared/treatmentTypeDisplay';
+import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
+import type { DayStripItem } from '@/components/treatment/dayStrip';
+
+interface DayStripCardProps {
+ item: DayStripItem;
+ selected: boolean;
+ treatmentCatalog: TreatmentCatalogEntry[];
+ onSelect: () => void;
+ canDelete?: boolean;
+ onDelete?: () => void;
+ deleteAriaLabel?: string;
+}
+
+export function DayStripCard({
+ item,
+ selected,
+ treatmentCatalog,
+ onSelect,
+ canDelete = false,
+ onDelete,
+ deleteAriaLabel,
+}: DayStripCardProps) {
+ const purposeIndex = treatmentCatalog.findIndex((e) => e.code === item.colorCode);
+ const bannerStyle = treatmentTypeBannerStyle(item.colorCode, purposeIndex < 0 ? 0 : purposeIndex);
+ const selectedClass = selected
+ ? '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';
+ const shellClass = `
+ text-start rounded-[var(--radius-sm)] w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
+ ${selectedClass}
+ `;
+
+ const body = (
+ <>
+ {item.timeLabel ? (
+
{item.timeLabel}
+ ) : (
+
{item.subtitle}
+ )}
+
+ {item.patientFirstName} {item.patientLastName}
+
+ {item.timeLabel ? (
+
{item.subtitle}
+ ) : null}
+ >
+ );
+
+ if (!canDelete || !onDelete) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index f9dccf7..97ae58f 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -33,6 +33,7 @@ import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
+import type { DayStripItem } from '@/components/treatment/dayStrip';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
@@ -59,7 +60,7 @@ import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithi
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
-import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
+import { APP_DATE, formatAppDate, formatAppTimeRange } from '@/lib/i18n/format';
import { useAuth } from '@/lib/hooks/useAuth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
@@ -144,7 +145,7 @@ function enrichDetailsWithLabSendState(
}
function buildWorkspaceSnapshot(
- appointment: TreatmentAppointment,
+ ctx: { id: string; patientId: string; treatmentAt: string; appointmentId: string | null },
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
title: string,
@@ -153,12 +154,12 @@ function buildWorkspaceSnapshot(
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
...detailsToPreviewTreatment(detailsForPreview, {
- id: id ?? `preview-${appointment.id}`,
+ id: id ?? `preview-${ctx.id}`,
title,
- patientId: appointment.patientId,
- treatmentAt: appointment.startAt,
+ patientId: ctx.patientId,
+ treatmentAt: ctx.treatmentAt,
}),
- appointmentId: appointment.id,
+ appointmentId: ctx.appointmentId,
labCases: labCaseDraftsToPast(labCaseDrafts, details),
};
}
@@ -233,7 +234,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
destinationOrganizationId: lc.destinationOrganizationId,
detailClientId: lc.detail?.clientId ?? null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
- detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
+ detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId ?? '',
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
@@ -349,9 +350,11 @@ export function TreatmentWorkspace({
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState
([]);
+ const [standaloneTreatments, setStandaloneTreatments] = useState([]);
const [apptsLoading, setApptsLoading] = useState(false);
const [selectionLocked, setSelectionLocked] = useState(false);
const [selectedAppointmentId, setSelectedAppointmentId] = useState(null);
+ const [selectedStandaloneId, setSelectedStandaloneId] = useState(null);
const [history, setHistory] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
@@ -460,6 +463,16 @@ export function TreatmentWorkspace({
[appointments, selectedAppointmentId],
);
+ const selectedStandalone = useMemo(
+ () => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
+ [standaloneTreatments, selectedStandaloneId],
+ );
+
+ const liveTreatmentId = selectedStandalone?.id ?? null;
+ const hasLiveContext = Boolean(selectedAppointment || selectedStandalone);
+
+ const walkInLabel = t('walkIn');
+
const activePatient = useMemo(() => {
if (selectedAppointment) {
return {
@@ -467,6 +480,17 @@ export function TreatmentWorkspace({
firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName,
purpose: selectedAppointment.purpose,
+ isWalkIn: false,
+ };
+ }
+ if (selectedStandalone) {
+ const isWalkIn = Boolean(selectedStandalone.patient?.isWalkIn);
+ return {
+ id: selectedStandalone.patientId,
+ firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
+ lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
+ purpose: selectedStandalone.details[0]?.treatmentType,
+ isWalkIn,
};
}
if (searchedPatient) {
@@ -475,14 +499,15 @@ export function TreatmentWorkspace({
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
purpose: undefined as string | undefined,
+ isWalkIn: false,
};
}
return null;
- }, [selectedAppointment, searchedPatient]);
+ }, [selectedAppointment, selectedStandalone, searchedPatient, walkInLabel]);
const activePatientId = activePatient?.id ?? null;
const activePatientName = activePatient
- ? `${activePatient.firstName} ${activePatient.lastName}`
+ ? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const unreadUpdatesCount = unreadLabCases.length;
@@ -504,10 +529,10 @@ export function TreatmentWorkspace({
: t('labShipmentsSubtitle');
useEffect(() => {
- if (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) {
+ if (selectedStandalone && searchedPatient?.id === selectedStandalone.patientId) {
setSearchedPatient(null);
}
- }, [selectedAppointment, searchedPatient?.id]);
+ }, [selectedAppointment, selectedStandalone, searchedPatient?.id]);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
@@ -516,7 +541,7 @@ export function TreatmentWorkspace({
const canEditTreatmentForDay =
canEdit &&
- Boolean(selectedAppointment) &&
+ hasLiveContext &&
!isViewingPastDay &&
workspaceMode === 'live';
@@ -554,18 +579,75 @@ export function TreatmentWorkspace({
[activeDetail, labDependentCodes],
);
+ const dayStripItems = useMemo(() => {
+ const timed: DayStripItem[] = appointments.map((a) => ({
+ kind: 'appointment',
+ id: a.id,
+ patientId: a.patientId,
+ patientFirstName: a.patientFirstName,
+ patientLastName: a.patientLastName,
+ patientIsWalkIn: false,
+ colorCode: a.purpose,
+ timeLabel: formatAppTimeRange(a.startAt, a.endAt, locale),
+ subtitle: treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog),
+ }));
+ const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
+ const isWalkIn = Boolean(tr.patient?.isWalkIn);
+ const colorCode = tr.details[0]?.treatmentType || 'visit';
+ return {
+ kind: 'unscheduled' as const,
+ id: tr.id,
+ patientId: tr.patientId,
+ patientFirstName: isWalkIn ? t('walkIn') : (tr.patient?.firstName ?? ''),
+ patientLastName: isWalkIn ? '' : (tr.patient?.lastName ?? ''),
+ patientIsWalkIn: isWalkIn,
+ colorCode,
+ timeLabel: null,
+ subtitle: t('noAppointment'),
+ canDelete: tr.details.length === 0,
+ };
+ });
+ return [...timed, ...unscheduled];
+ }, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
+
+ const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
+
const currentDraftPreview = useMemo(() => {
- if (!selectedAppointment) return null;
- return buildWorkspaceSnapshot(
- selectedAppointment,
- details,
- labCaseDrafts,
- t('treatmentPlanTitle', {
- patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
- }),
- 'current-draft',
- );
- }, [details, labCaseDrafts, selectedAppointment, t]);
+ if (selectedAppointment) {
+ return buildWorkspaceSnapshot(
+ {
+ id: selectedAppointment.id,
+ patientId: selectedAppointment.patientId,
+ treatmentAt: selectedAppointment.startAt,
+ appointmentId: selectedAppointment.id,
+ },
+ details,
+ labCaseDrafts,
+ t('treatmentPlanTitle', {
+ patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
+ }),
+ 'current-draft',
+ );
+ }
+ if (selectedStandalone) {
+ const name = selectedStandalone.patient?.isWalkIn
+ ? t('walkIn')
+ : `${selectedStandalone.patient?.firstName ?? ''} ${selectedStandalone.patient?.lastName ?? ''}`.trim();
+ return buildWorkspaceSnapshot(
+ {
+ id: selectedStandalone.id,
+ patientId: selectedStandalone.patientId,
+ treatmentAt: selectedStandalone.treatmentAt,
+ appointmentId: null,
+ },
+ details,
+ labCaseDrafts,
+ t('treatmentPlanTitle', { patientName: name || t('walkIn') }),
+ selectedStandalone.id,
+ );
+ }
+ return null;
+ }, [details, labCaseDrafts, selectedAppointment, selectedStandalone, t]);
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
@@ -678,25 +760,43 @@ export function TreatmentWorkspace({
try {
const dayStart = startOfLocalDay(selectedDay);
const dayEnd = addCalendarDays(dayStart, 1);
- const response = await appointmentsApi.list({
- from: dayStart.toISOString(),
- to: dayEnd.toISOString(),
- });
+ const [apptsResponse, standaloneResponse] = await Promise.all([
+ appointmentsApi.list({
+ from: dayStart.toISOString(),
+ to: dayEnd.toISOString(),
+ }),
+ treatmentsApi.listDayStandalone({
+ from: dayStart.toISOString(),
+ to: dayEnd.toISOString(),
+ }),
+ ]);
if (cancelled) return;
- const list = response.data
+ const list = apptsResponse.data
.filter((a) => a.providerUserId === userId)
.map(mapAppointment);
setAppointments(list);
+ setStandaloneTreatments(standaloneResponse.data);
if (!selectionLockedRef.current) {
const pendingId = pendingAppointmentIdRef.current;
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
setSelectedAppointmentId(pendingId);
+ setSelectedStandaloneId(null);
setSelectionLocked(true);
pendingAppointmentIdRef.current = null;
router.replace('/treatment', { scroll: false });
} else {
pendingAppointmentIdRef.current = null;
- setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
+ const autoAppt = pickAutoAppointment(list, selectedDay);
+ if (autoAppt) {
+ setSelectedAppointmentId(autoAppt);
+ setSelectedStandaloneId(null);
+ } else if (standaloneResponse.data[0]) {
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(standaloneResponse.data[0].id);
+ } else {
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(null);
+ }
}
}
} catch (error: unknown) {
@@ -866,7 +966,8 @@ export function TreatmentWorkspace({
useEffect(() => {
const appointmentId = selectedAppointment?.id;
- if (!appointmentId || workspaceMode !== 'live') return;
+ const treatmentId = selectedStandalone?.id;
+ if ((!appointmentId && !treatmentId) || workspaceMode !== 'live') return;
if (skipNextGetDraftRef.current) {
skipNextGetDraftRef.current = false;
@@ -882,7 +983,9 @@ export function TreatmentWorkspace({
void (async () => {
try {
- const response = await treatmentsApi.getDraft(appointmentId);
+ const response = appointmentId
+ ? await treatmentsApi.getDraft(appointmentId)
+ : await treatmentsApi.getDraftByTreatment(treatmentId!);
if (cancelled) return;
if (response.data?.details?.length) {
@@ -923,36 +1026,49 @@ export function TreatmentWorkspace({
cancelled = true;
draftHydratingRef.current = false;
};
- }, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
+ }, [
+ selectedAppointment?.id,
+ selectedAppointment?.purpose,
+ selectedStandalone?.id,
+ workspaceMode,
+ treatmentCatalog,
+ showError,
+ t,
+ tErrors,
+ ]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
- if (!selectedAppointment) throw new Error('No appointment selected');
+ if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
+
+ const patientName = selectedAppointment
+ ? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
+ : selectedStandalone?.patient?.isWalkIn
+ ? t('walkIn')
+ : `${selectedStandalone?.patient?.firstName ?? ''} ${selectedStandalone?.patient?.lastName ?? ''}`.trim();
+ const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
+ const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
const currentDetails = detailsRef.current;
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
if (!options?.force && !dirty) {
return detailsToPreviewTreatment(currentDetails, {
- title: t('treatmentPlanTitle', {
- patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
- }),
- patientId: selectedAppointment.patientId,
- treatmentAt: selectedAppointment.startAt,
+ title: t('treatmentPlanTitle', { patientName }),
+ patientId,
+ treatmentAt,
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
- title: t('treatmentPlanTitle', {
- patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
- }),
- patientId: selectedAppointment.patientId,
- treatmentAt: selectedAppointment.startAt,
+ title: t('treatmentPlanTitle', { patientName }),
+ patientId,
+ treatmentAt,
});
}
- const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
+ const payload = {
details: currentDetails.map(
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
clientId,
@@ -964,7 +1080,10 @@ export function TreatmentWorkspace({
attachmentIds: attachmentMetas.map((a) => a.id),
}),
),
- });
+ };
+ const response = selectedAppointment
+ ? await treatmentsApi.saveDraft(selectedAppointment.id, payload)
+ : await treatmentsApi.saveDraftByTreatment(selectedStandalone!.id, payload);
const mapped = response.data.details.map(mapDetailFromApi);
const sentSnapshot = serializeDetails(currentDetails);
const localNow = detailsRef.current;
@@ -990,7 +1109,7 @@ export function TreatmentWorkspace({
}
return response.data;
},
- [selectedAppointment, t],
+ [selectedAppointment, selectedStandalone, t],
);
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
@@ -1007,7 +1126,7 @@ export function TreatmentWorkspace({
}, [refreshPatientLabCases, showError, t, tErrors]);
const runDraftSave = useCallback(async () => {
- if (!selectedAppointment || saveInFlightRef.current) {
+ if (!hasLiveContext || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
return;
}
@@ -1040,7 +1159,7 @@ export function TreatmentWorkspace({
}
}
}
- }, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
+ }, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise => {
if (autosaveTimerRef.current) {
@@ -1048,7 +1167,7 @@ export function TreatmentWorkspace({
autosaveTimerRef.current = null;
}
- if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
+ if (workspaceModeRef.current !== 'live' || !hasLiveContext || !canEditTreatmentForDay) {
return true;
}
@@ -1066,10 +1185,10 @@ export function TreatmentWorkspace({
} catch {
return window.confirm(t('confirmDiscard'));
}
- }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
+ }, [hasLiveContext, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
- if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
+ if (draftHydratingRef.current || !canEditTreatmentForDay || !hasLiveContext) {
return;
}
@@ -1091,7 +1210,7 @@ export function TreatmentWorkspace({
autosaveTimerRef.current = null;
}
};
- }, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
+ }, [details, isDirty, canEditTreatmentForDay, hasLiveContext, runDraftSave]);
const resetToLiveContext = useCallback(() => {
setWorkspaceMode('live');
@@ -1107,11 +1226,90 @@ export function TreatmentWorkspace({
setSearchedPatient(null);
setSelectionLocked(true);
setSelectedAppointmentId(id);
+ setSelectedStandaloneId(null);
})();
},
[flushDraftSave, resetToLiveContext],
);
+ const onPickStripItem = useCallback(
+ (item: DayStripItem) => {
+ void (async () => {
+ const ok = await flushDraftSave();
+ if (!ok) return;
+ resetToLiveContext();
+ setSearchedPatient(null);
+ setSelectionLocked(true);
+ if (item.kind === 'appointment') {
+ setSelectedAppointmentId(item.id);
+ setSelectedStandaloneId(null);
+ } else {
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(item.id);
+ }
+ })();
+ },
+ [flushDraftSave, resetToLiveContext],
+ );
+
+ const createStandaloneTreatment = useCallback(
+ async (opts: { patientId?: string; walkIn?: boolean }) => {
+ const ok = await flushDraftSave();
+ if (!ok) return;
+ try {
+ const created = await treatmentsApi.createStandalone({
+ ...opts,
+ treatmentAt: selectedDay.toISOString(),
+ });
+ resetToLiveContext();
+ setSelectionLocked(true);
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(created.data.id);
+ setStandaloneTreatments((prev) =>
+ prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
+ );
+ skipNextGetDraftRef.current = true;
+ hydrateFromTreatment(created.data);
+ if (created.data.patientId && !created.data.patient?.isWalkIn) {
+ await refreshHistory(created.data.patientId);
+ }
+ } catch (error: unknown) {
+ showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
+ }
+ },
+ [flushDraftSave, selectedDay, resetToLiveContext, hydrateFromTreatment, refreshHistory, showError, t, tErrors],
+ );
+
+ const deleteStandaloneTreatment = useCallback(
+ async (item: DayStripItem) => {
+ if (!canEdit || item.kind !== 'unscheduled' || !item.canDelete) return;
+ if (!window.confirm(t('confirmDeleteEmptyTreatment'))) return;
+ if (autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current);
+ autosaveTimerRef.current = null;
+ }
+ try {
+ await treatmentsApi.deleteStandalone(item.id);
+ setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
+ if (selectedStandaloneId === item.id) {
+ resetToLiveContext();
+ setSelectedStandaloneId(null);
+ setSelectedAppointmentId(null);
+ }
+ } catch (error: unknown) {
+ showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
+ }
+ },
+ [
+ canEdit,
+ resetToLiveContext,
+ selectedStandaloneId,
+ showError,
+ t,
+ tErrors,
+ ],
+ );
+
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback(
@@ -1123,6 +1321,8 @@ export function TreatmentWorkspace({
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(false);
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(null);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
@@ -1146,17 +1346,14 @@ export function TreatmentWorkspace({
focusDetailClientId?: string,
options?: { scrollToLabPanel?: boolean },
) => {
- if (!treatment.appointmentId) {
- showError(t('errorNoAppointmentForTreatment'));
- return false;
- }
-
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return false;
let treatmentToLoad = treatment;
try {
- const draftResponse = await treatmentsApi.getDraft(treatment.appointmentId);
+ const draftResponse = treatment.appointmentId
+ ? await treatmentsApi.getDraft(treatment.appointmentId)
+ : await treatmentsApi.getDraftByTreatment(treatment.id);
if (draftResponse.data) {
treatmentToLoad = draftResponse.data;
}
@@ -1170,7 +1367,16 @@ export function TreatmentWorkspace({
const nextDay = startOfLocalDay(new Date(treatmentToLoad.treatmentAt));
setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay));
setSelectionLocked(true);
- setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId);
+ if (treatmentToLoad.appointmentId ?? treatment.appointmentId) {
+ setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId ?? null);
+ setSelectedStandaloneId(null);
+ } else {
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(treatmentToLoad.id);
+ setStandaloneTreatments((prev) =>
+ prev.some((row) => row.id === treatmentToLoad.id) ? prev : [...prev, treatmentToLoad],
+ );
+ }
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
@@ -1204,12 +1410,20 @@ export function TreatmentWorkspace({
return true;
},
- [flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
+ [flushDraftSave, hydrateFromTreatment, todayStart],
);
const handleSelectSearchedPatient = useCallback(
(patient: Patient) => {
void (async () => {
+ const alreadySelected =
+ selectedAppointment?.patientId === patient.id ||
+ selectedStandalone?.patientId === patient.id ||
+ searchedPatient?.id === patient.id;
+ if (alreadySelected && hasLiveContext) {
+ return;
+ }
+
setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
@@ -1219,24 +1433,48 @@ export function TreatmentWorkspace({
try {
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
- if (!latest) {
- showError(t('errorNoTreatmentForPatient'));
- setSearchedPatient(null);
+ if (latest) {
+ const ok = await loadTreatmentIntoWorkspace(latest);
+ if (!ok) {
+ setSearchedPatient(null);
+ }
return;
}
- const ok = await loadTreatmentIntoWorkspace(latest);
- if (!ok) {
- setSearchedPatient(null);
- }
+
+ const created = await treatmentsApi.createStandalone({
+ patientId: patient.id,
+ treatmentAt: selectedDay.toISOString(),
+ });
+ setSelectionLocked(true);
+ setSelectedAppointmentId(null);
+ setSelectedStandaloneId(created.data.id);
+ setStandaloneTreatments((prev) =>
+ prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
+ );
+ skipNextGetDraftRef.current = true;
+ hydrateFromTreatment(created.data);
+ await refreshHistory(patient.id);
} catch (error: unknown) {
- showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient')));
+ showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
setSearchedPatient(null);
} finally {
setPatientSearchBusy(false);
}
})();
},
- [loadTreatmentIntoWorkspace, showError, t, tErrors],
+ [
+ selectedAppointment?.patientId,
+ selectedStandalone?.patientId,
+ searchedPatient?.id,
+ hasLiveContext,
+ loadTreatmentIntoWorkspace,
+ selectedDay,
+ hydrateFromTreatment,
+ refreshHistory,
+ showError,
+ t,
+ tErrors,
+ ],
);
const handleLoadIntoWorkspace = useCallback(() => {
@@ -1386,7 +1624,7 @@ export function TreatmentWorkspace({
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
- if (!canEditTreatmentForDay || !selectedAppointment) return;
+ if (!canEditTreatmentForDay || !hasLiveContext) return;
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
if (target && isDetailLocked(target)) return;
const list = files instanceof FileList ? Array.from(files) : files;
@@ -1394,11 +1632,17 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(detailClientId);
try {
- const uploaded = await treatmentsApi.uploadCaseAttachments(
- selectedAppointment.id,
- detailClientId,
- list,
- );
+ const uploaded = selectedAppointment
+ ? await treatmentsApi.uploadCaseAttachments(
+ selectedAppointment.id,
+ detailClientId,
+ list,
+ )
+ : await treatmentsApi.uploadDetailAttachmentsByTreatment(
+ selectedStandalone!.id,
+ detailClientId,
+ list,
+ );
setDetails((prev) =>
prev.map((d) =>
d.clientId === detailClientId
@@ -1413,12 +1657,12 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(null);
}
},
- [canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors],
+ [canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
- if (!selectedAppointment) throw new Error('No appointment selected');
+ if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
@@ -1467,9 +1711,13 @@ export function TreatmentWorkspace({
return savedTreatment;
}
- const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
- labCases: payload,
- });
+ const response = selectedAppointment
+ ? await treatmentsApi.saveLabCases(selectedAppointment.id, {
+ labCases: payload,
+ })
+ : await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
+ labCases: payload,
+ });
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
@@ -1478,7 +1726,7 @@ export function TreatmentWorkspace({
});
return response.data;
},
- [labCaseDrafts, selectedAppointment],
+ [labCaseDrafts, selectedAppointment, selectedStandalone],
);
const handleLabCasesChange = useCallback(
@@ -1498,7 +1746,7 @@ export function TreatmentWorkspace({
);
if (
removedPersistedDraft &&
- selectedAppointment &&
+ hasLiveContext &&
canEditTreatmentForDay
) {
void (async () => {
@@ -1565,7 +1813,7 @@ export function TreatmentWorkspace({
);
const handleAddLabCase = useCallback(async () => {
- if (!canEditTreatmentForDay || !selectedAppointment) return;
+ if (!canEditTreatmentForDay || !hasLiveContext) return;
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const existing = cleaned.find(
@@ -1636,7 +1884,7 @@ export function TreatmentWorkspace({
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
useEffect(() => {
if (entryStep !== 'lab') return;
- if (!canEditTreatmentForDay || !selectedAppointment) return;
+ if (!canEditTreatmentForDay || !hasLiveContext) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
@@ -1660,7 +1908,7 @@ export function TreatmentWorkspace({
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft, comment?: string) => {
- if (!canEditTreatmentForDay || !selectedAppointment) return;
+ if (!canEditTreatmentForDay || !hasLiveContext) return;
if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
return;
@@ -1695,7 +1943,9 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
- const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
+ const draftResponse = selectedAppointment
+ ? await treatmentsApi.getDraft(selectedAppointment.id)
+ : await treatmentsApi.getDraftByTreatment(selectedStandalone!.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
detailsRef.current = mapped;
@@ -1746,9 +1996,11 @@ export function TreatmentWorkspace({
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
- if (selectedAppointment.patientId) {
+ if (selectedAppointment?.patientId) {
// Silent: avoid rail/history loading flicker while staying on Lab step.
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
+ } else if (selectedStandalone?.patientId) {
+ void refreshHistory(selectedStandalone.patientId, { silentLabCases: true });
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
@@ -1792,9 +2044,10 @@ export function TreatmentWorkspace({
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
- appointments={appointments}
- selectedAppointmentId={selectedAppointmentId}
- onSelectAppointment={onPickAppointment}
+ items={dayStripItems}
+ selectedItemId={selectedStripItemId}
+ onSelectItem={onPickStripItem}
+ onDeleteUnscheduled={canEdit ? deleteStandaloneTreatment : undefined}
treatmentCatalog={treatmentCatalog}
loading={apptsLoading}
/>
@@ -1823,6 +2076,39 @@ export function TreatmentWorkspace({
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
+ {canEdit && !isViewingPastDay ? (
+
+
+
+
+ ) : null}
{activePatient ? (
@@ -1844,7 +2130,7 @@ export function TreatmentWorkspace({
)}
- {workspaceMode === 'live' && !isBrowsing && selectedAppointment ? (
+ {workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
;
+ comment?: string;
+ toothProsthesis?: Array<{
+ tooth: string;
+ prosthesisTypeCode: string;
+ selectionGroupId?: string;
+ }>;
+ attachmentIds?: string[];
+}
+
+export interface LabInternalCasePayload {
+ referringClinicName?: string | null;
+ referringDentistName?: string | null;
+ patientDisplayName?: string | null;
+ patientDisplayMobile?: string | null;
+ partnerClinicOrganizationId?: string | null;
+ dueDate?: string | null;
+ lines?: LabInternalCaseLinePayload[];
+}
export const casesApi = {
list: async (
@@ -15,6 +41,48 @@ export const casesApi = {
return response.data;
},
+ create: async (
+ payload: LabInternalCasePayload = {},
+ ): Promise<{ success: boolean; data: LabCaseDetail }> => {
+ const response = await apiClient.post('/cases', payload);
+ return response.data;
+ },
+
+ update: async (
+ id: string,
+ payload: LabInternalCasePayload,
+ ): Promise<{ success: boolean; data: LabCaseDetail }> => {
+ const response = await apiClient.put(`/cases/${id}`, payload);
+ return response.data;
+ },
+
+ start: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => {
+ const response = await apiClient.post(`/cases/${id}/start`);
+ return response.data;
+ },
+
+ uploadLineAttachments: async (
+ caseId: string,
+ lineClientId: string,
+ files: File[],
+ ): Promise<{ success: boolean; data: LabCaseAttachmentMeta[] }> => {
+ const form = new FormData();
+ for (const file of files) {
+ form.append('files', file);
+ }
+ const response = await apiClient.post(
+ `/cases/${caseId}/lines/${encodeURIComponent(lineClientId)}/attachments`,
+ form,
+ { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
+ );
+ return response.data;
+ },
+
+ listLinkedClinics: async (): Promise<{ success: boolean; data: LinkedOrganizationOption[] }> => {
+ const response = await apiClient.get('/cases/linked-clinics');
+ return response.data;
+ },
+
getOne: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.get(`/cases/${id}`);
return response.data;
diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts
index e7bc414..edf57f3 100644
--- a/frontend/src/lib/api/treatments.ts
+++ b/frontend/src/lib/api/treatments.ts
@@ -46,6 +46,68 @@ export const treatmentsApi = {
return response.data;
},
+ createStandalone: async (payload: {
+ patientId?: string;
+ walkIn?: boolean;
+ treatmentAt: string;
+ }): Promise<{ success: boolean; data: PastTreatment }> => {
+ const response = await apiClient.post('/treatments', payload);
+ return response.data;
+ },
+
+ deleteStandalone: async (treatmentId: string): Promise<{ success: boolean }> => {
+ const response = await apiClient.delete(`/treatments/${treatmentId}`);
+ return response.data;
+ },
+
+ listDayStandalone: async (params: {
+ from: string;
+ to: string;
+ }): Promise<{ success: boolean; data: PastTreatment[] }> => {
+ const response = await apiClient.get('/treatments/day', { params });
+ return response.data;
+ },
+
+ getDraftByTreatment: async (
+ treatmentId: string,
+ ): Promise<{ success: boolean; data: PastTreatment | null }> => {
+ const response = await apiClient.get(`/treatments/${treatmentId}/draft`);
+ return response.data;
+ },
+
+ saveDraftByTreatment: async (
+ treatmentId: string,
+ payload: { details: SavedTreatmentDetailPayload[] },
+ ): Promise<{ success: boolean; data: PastTreatment }> => {
+ const response = await apiClient.put(`/treatments/${treatmentId}/draft`, payload);
+ return response.data;
+ },
+
+ saveLabCasesByTreatment: async (
+ treatmentId: string,
+ payload: { labCases: SaveLabCasePayload[] },
+ ): Promise<{ success: boolean; data: PastTreatment }> => {
+ const response = await apiClient.put(`/treatments/${treatmentId}/lab-cases`, payload);
+ return response.data;
+ },
+
+ uploadDetailAttachmentsByTreatment: async (
+ treatmentId: string,
+ detailClientId: string,
+ files: File[],
+ ): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
+ const form = new FormData();
+ for (const file of files) {
+ form.append('files', file);
+ }
+ const response = await apiClient.post(
+ `/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
+ form,
+ { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
+ );
+ return response.data;
+ },
+
saveDraft: async (
appointmentId: string,
payload: { details: SavedTreatmentDetailPayload[] },
diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts
index 54fcf76..ea3a1b2 100644
--- a/frontend/src/types/cases.ts
+++ b/frontend/src/types/cases.ts
@@ -10,6 +10,8 @@ export interface LabCaseProsthesisGroup {
export interface LabCaseListItem {
id: string;
sentAt: string | null;
+ startedAt?: string | null;
+ origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
@@ -86,16 +88,33 @@ export interface LabCaseAttachmentMeta {
mimeType: string;
sizeBytes: number;
createdAt: string;
+ detailClientKey?: string | null;
+}
+
+export interface LabCaseLine {
+ id: string;
+ clientId: string;
+ treatmentType: string;
+ teeth: string[];
+ toothSelectionGroups?: unknown;
+ comment: string | null;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
+ startedAt?: string | null;
+ origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
/** Optional external lab-app code (e.g. exocad); used as PDF order number when set. */
externalCode?: string | null;
+ referringClinicName?: string | null;
+ referringDentistName?: string | null;
+ patientDisplayName?: string | null;
+ patientDisplayMobile?: string | null;
+ partnerClinicOrganizationId?: string | null;
clinic: { id: string; name: string };
patient: {
id: string;
@@ -111,8 +130,10 @@ export interface LabCaseDetail {
teeth: string[];
comment: string | null;
} | null;
+ lines?: LabCaseLine[];
toothProsthesis: Array<{
- treatmentDetailId: string;
+ treatmentDetailId: string | null;
+ lineId?: string | null;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts
index 861e805..16432ac 100644
--- a/frontend/src/types/treatment.ts
+++ b/frontend/src/types/treatment.ts
@@ -116,7 +116,7 @@ export interface PastLabCase {
toothSelectionGroups?: ToothSelectionGroup[];
} | null;
toothProsthesis?: Array<{
- treatmentDetailId: string;
+ treatmentDetailId?: string | null;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
@@ -131,8 +131,15 @@ export interface PastTreatment {
id: string;
patientId: string;
appointmentId?: string | null;
+ providerUserId?: string | null;
title: string;
treatmentAt: string;
+ patient?: {
+ id: string;
+ firstName: string;
+ lastName: string;
+ isWalkIn: boolean;
+ } | null;
details: PastTreatmentDetail[];
labCases: PastLabCase[];
documents: TreatmentAttachmentMeta[];