improvement: UI/UX improved for v1 standalone treatments/cases feature.

This commit is contained in:
2026-08-19 16:07:32 +03:30
parent 8bfa8c88fe
commit 96f698be98
24 changed files with 320 additions and 121 deletions

View File

@@ -13,6 +13,7 @@ import {
} from '@/components/ui/treatment/TreatmentLabCasesPanel';
import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
@@ -370,6 +371,8 @@ export function TreatmentWorkspace({
'id' | 'firstName' | 'lastName'
> | null>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false);
const {
search: patientSearch,
@@ -680,12 +683,16 @@ export function TreatmentWorkspace({
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.details.map(mapDetailFromApi);
setDetails(mapped);
const nextDetails =
mapped.length > 0
? mapped
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
setDetails(nextDetails);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
const stillExists = nextDetails.some((d) => d.clientId === prev);
return stillExists ? prev : nextDetails[0].clientId;
});
setSavedSnapshot(serializeDetails(mapped));
setSavedSnapshot(serializeDetails(nextDetails));
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
@@ -693,7 +700,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
}, [treatmentCatalog]);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
@@ -1254,14 +1261,18 @@ export function TreatmentWorkspace({
const createStandaloneTreatment = useCallback(
async (opts: { patientId?: string; walkIn?: boolean }) => {
if (creatingStandalone) return;
const ok = await flushDraftSave();
if (!ok) return;
setCreatingStandalone(true);
try {
const created = await treatmentsApi.createStandalone({
...opts,
treatmentAt: selectedDay.toISOString(),
});
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(true);
setSelectedAppointmentId(null);
setSelectedStandaloneId(created.data.id);
@@ -1269,15 +1280,29 @@ export function TreatmentWorkspace({
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
);
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
hydrateFromTreatment(created.data);
draftHydratingRef.current = false;
if (created.data.patientId && !created.data.patient?.isWalkIn) {
await refreshHistory(created.data.patientId);
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
} finally {
setCreatingStandalone(false);
}
},
[flushDraftSave, selectedDay, resetToLiveContext, hydrateFromTreatment, refreshHistory, showError, t, tErrors],
[
creatingStandalone,
flushDraftSave,
selectedDay,
resetToLiveContext,
hydrateFromTreatment,
refreshHistory,
showError,
t,
tErrors,
],
);
const deleteStandaloneTreatment = useCallback(
@@ -1320,6 +1345,7 @@ export function TreatmentWorkspace({
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
@@ -1441,21 +1467,9 @@ export function TreatmentWorkspace({
return;
}
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('errorCreateTreatment')));
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null);
} finally {
setPatientSearchBusy(false);
@@ -1468,8 +1482,6 @@ export function TreatmentWorkspace({
searchedPatient?.id,
hasLiveContext,
loadTreatmentIntoWorkspace,
selectedDay,
hydrateFromTreatment,
refreshHistory,
showError,
t,
@@ -2077,36 +2089,26 @@ export function TreatmentWorkspace({
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="flex flex-wrap gap-2">
<div className="space-y-2">
<Button
type="button"
size="sm"
disabled={
!(
searchedPatient?.id ||
selectedAppointment?.patientId ||
(selectedStandalone && !selectedStandalone.patient?.isWalkIn)
)
}
onClick={() => {
const patientId =
searchedPatient?.id ??
selectedAppointment?.patientId ??
selectedStandalone?.patientId;
if (!patientId) return;
return createStandaloneTreatment({ patientId });
}}
size="lg"
fullWidth
aria-expanded={newTreatmentPickerOpen}
onClick={() => setNewTreatmentPickerOpen((open) => !open)}
>
{t('newTreatment')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => createStandaloneTreatment({ walkIn: true })}
>
{t('newWalkInTreatment')}
</Button>
{newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker
creating={creatingStandalone}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id })
}
onCancel={() => setNewTreatmentPickerOpen(false)}
/>
) : null}
</div>
) : null}