improvement: UI/UX improved for v1 standalone treatments/cases feature.
This commit is contained in:
@@ -16,6 +16,25 @@ interface DayStripCardProps {
|
||||
deleteAriaLabel?: string;
|
||||
}
|
||||
|
||||
function StripCardBody({ item }: { item: DayStripItem }) {
|
||||
const metaClass = item.kind === 'appointment' ? 'opacity-95' : '';
|
||||
return (
|
||||
<>
|
||||
{item.timeLabel ? (
|
||||
<p className={`text-[11px] font-medium tabular-nums ${metaClass}`}>{item.timeLabel}</p>
|
||||
) : (
|
||||
<p className={`text-[11px] font-medium ${metaClass}`}>{item.subtitle}</p>
|
||||
)}
|
||||
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
||||
{item.patientFirstName} {item.patientLastName}
|
||||
</p>
|
||||
{item.timeLabel ? (
|
||||
<p className={`text-[11px] mt-0.5 truncate ${metaClass}`}>{item.subtitle}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayStripCard({
|
||||
item,
|
||||
selected,
|
||||
@@ -25,73 +44,70 @@ export function DayStripCard({
|
||||
onDelete,
|
||||
deleteAriaLabel,
|
||||
}: DayStripCardProps) {
|
||||
const shellClass = `
|
||||
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
|
||||
`;
|
||||
|
||||
if (item.kind === 'unscheduled') {
|
||||
const selectedClass = selected
|
||||
? 'border-primary bg-primary-soft'
|
||||
: 'border-border/70 hover:border-border hover:bg-background-card/50';
|
||||
const labelClass = selected ? 'font-medium text-text-primary' : 'text-text-secondary';
|
||||
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
|
||||
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border ${selectedClass} ${shellClass}`}
|
||||
>
|
||||
<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 ${labelClass}`}
|
||||
>
|
||||
<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
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
|
||||
${trashClass}
|
||||
`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<p className="text-[11px] font-medium tabular-nums opacity-95">{item.timeLabel}</p>
|
||||
) : (
|
||||
<p className="text-[11px] font-medium opacity-95">{item.subtitle}</p>
|
||||
)}
|
||||
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
||||
{item.patientFirstName} {item.patientLastName}
|
||||
</p>
|
||||
{item.timeLabel ? (
|
||||
<p className="text-[11px] opacity-90 mt-0.5 truncate">{item.subtitle}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canDelete || !onDelete) {
|
||||
return (
|
||||
<Card
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
padding="none"
|
||||
style={bannerStyle}
|
||||
className={`${shellClass} px-3 py-2`}
|
||||
>
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
padding="none"
|
||||
style={bannerStyle}
|
||||
className={`${shellClass} flex items-stretch overflow-hidden`}
|
||||
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="min-w-0 flex-1 text-start px-3 py-2 focus:outline-none"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label={deleteAriaLabel}
|
||||
title={deleteAriaLabel}
|
||||
className="shrink-0 self-center pe-2.5 ps-1 opacity-80 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-[var(--radius-sm)] text-inherit"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-inherit" aria-hidden />
|
||||
</button>
|
||||
<StripCardBody item={item} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||
import type { Patient } from '@/types/patient';
|
||||
|
||||
interface NewTreatmentPatientPickerProps {
|
||||
creating?: boolean;
|
||||
onSelectWalkIn: () => void | Promise<void>;
|
||||
onSelectPatient: (patient: Patient) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function NewTreatmentPatientPicker({
|
||||
creating = false,
|
||||
onSelectWalkIn,
|
||||
onSelectPatient,
|
||||
onCancel,
|
||||
}: NewTreatmentPatientPickerProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const tPatients = useTranslations('patients');
|
||||
const { search, setSearch, patients, loading } = usePatientSearchQuery(!creating);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/40 p-3">
|
||||
<p className="text-sm font-medium text-text-primary">{t('newTreatmentPatientPrompt')}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating}
|
||||
onClick={() => void onSelectWalkIn()}
|
||||
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
|
||||
</button>
|
||||
<PatientSearchCombobox
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
patients={patients}
|
||||
loading={loading || creating}
|
||||
onSelectPatient={(patient) => {
|
||||
if (creating) return;
|
||||
void onSelectPatient(patient);
|
||||
}}
|
||||
placeholder={tPatients('searchPlaceholder')}
|
||||
emptyResultsMessage={tPatients('noResults')}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" disabled={creating} onClick={onCancel}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user