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

@@ -0,0 +1,5 @@
export type LabCaseOriginCode = 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
export function isLabGeneratedCase(origin?: string | null): boolean {
return origin === 'LAB_INTERNAL';
}

View File

@@ -16,6 +16,10 @@ export function parseTasksSearchParams(
if (searchParams.get('unassignedOnly') === '1') {
partial.unassignedOnly = true;
}
const origin = searchParams.get('origin');
if (origin === 'CLINIC_DISPATCH' || origin === 'LAB_INTERNAL') {
partial.origin = origin;
}
const status = searchParams.get('status');
if (status === 'IN_PROGRESS' || status === 'COMPLETED') {

View File

@@ -17,6 +17,7 @@ export type CaseTaskGroup = {
caseDueDate: string | null;
isCaseOverdue: boolean;
isImportant: boolean;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
prosthesisGroups: ProsthesisTaskGroup[];
};
@@ -52,6 +53,7 @@ export function groupTasksForDisplay(
caseDueDate: task.caseDueDate ?? null,
isCaseOverdue: task.isCaseOverdue,
isImportant: task.isImportant,
origin: task.origin,
prosthesisGroups: [],
});
}

View File

@@ -11,6 +11,7 @@ export type TasksViewState = {
assignedToMe: boolean;
overdueOnly: boolean;
unassignedOnly: boolean;
origin: '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
prosthesisTypeCode: string;
page: number;
highlightTaskId: string | null;
@@ -27,6 +28,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
assignedToMe: false,
overdueOnly: false,
unassignedOnly: false,
origin: '',
prosthesisTypeCode: '',
page: 1,
highlightTaskId: null,
@@ -46,6 +48,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly &&
state.origin === DEFAULT_TASKS_VIEW.origin &&
state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode &&
state.page === DEFAULT_TASKS_VIEW.page &&
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/ui/lab/LabCaseShareQrDialog';
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
@@ -165,6 +166,7 @@ export function CaseDetailPanel({
{formatPatientName(labCase.patient)}
</h2>
<LabCaseDueDateBadge dueDate={labCase.dueDate} locale={locale} />
<LabCaseOriginBadge origin={labCase.origin} />
</div>
{!canEditImportant && labCase.isImportant ? (
<Badge variant="warning" fixedWidth={false} className="mt-1">

View File

@@ -16,6 +16,8 @@ import {
formatPatientName,
} from '@/components/lab/caseDetailUtils';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
import { notificationsApi } from '@/lib/api/notifications';
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { casesApi } from '@/lib/api/cases';
@@ -529,6 +531,7 @@ export function CasesPage() {
{t('importantLabel')}
</Badge>
) : null}
<LabCaseOriginBadge origin={item.origin} className="text-[10px]" />
{isDraftCase(item) ? (
<Badge variant="default" fixedWidth={false}>
{t('draftBadge')}
@@ -694,6 +697,7 @@ export function CasesPage() {
viewerSide="LAB"
canPost={canEditComments}
canToggleVisibility={canEditComments}
clinicVisibility={!isLabGeneratedCase(selectedCase.origin)}
loadComments={async () => {
const r = await tasksApi.listComments(selectedCaseId);
setCommentCount(r.data.length);

View File

@@ -14,6 +14,11 @@ interface LabCaseCommentsPanelProps {
viewerSide: LabCaseCommentViewerSide;
canPost: boolean;
canToggleVisibility: boolean;
/**
* When false (lab-generated cases with no clinic send), hide clinic-visibility
* controls and status. Defaults to true for clinic-sent cases.
*/
clinicVisibility?: boolean;
loadComments: () => Promise<LabCaseComment[]>;
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
@@ -38,6 +43,7 @@ export function LabCaseCommentsPanel({
viewerSide,
canPost,
canToggleVisibility,
clinicVisibility = true,
loadComments,
onPost,
onToggleVisibility,
@@ -55,6 +61,8 @@ export function LabCaseCommentsPanel({
const [visibleToClinic, setVisibleToClinic] = useState(false);
const toggleBusy = useAsyncActionById();
const allowClinicVisibility = canToggleVisibility && clinicVisibility;
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
const loadCommentsRef = useRef(loadComments);
loadCommentsRef.current = loadComments;
@@ -80,7 +88,7 @@ export function LabCaseCommentsPanel({
if (!trimmed || !canPost || posting) return;
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
const created = await onPost(trimmed, clinicVisibility ? visibleToClinic : false);
setComments((prev) => sortNewestFirst([created, ...prev]));
setBody('');
setVisibleToClinic(false);
@@ -99,7 +107,7 @@ export function LabCaseCommentsPanel({
}
async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return;
if (!onToggleVisibility || !allowClinicVisibility) return;
await toggleBusy.run(comment.id, async () => {
try {
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
@@ -142,14 +150,14 @@ export function LabCaseCommentsPanel({
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
</span>
{comment.showVisibilityStatus !== false ? (
{clinicVisibility && comment.showVisibilityStatus !== false ? (
comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)
) : null}
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
{allowClinicVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => handleToggle(comment)}
@@ -198,7 +206,7 @@ export function LabCaseCommentsPanel({
className={composerInputClass}
/>
<div className="flex items-center gap-1 shrink-0">
{canToggleVisibility ? (
{allowClinicVisibility ? (
<button
type="button"
onClick={() => setVisibleToClinic((v) => !v)}

View File

@@ -0,0 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/shared/Badge';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
interface LabCaseOriginBadgeProps {
origin?: string | null;
className?: string;
}
export function LabCaseOriginBadge({ origin, className }: LabCaseOriginBadgeProps) {
const t = useTranslations('cases');
const generated = isLabGeneratedCase(origin);
return (
<Badge
variant={generated ? 'default' : 'success'}
fixedWidth={false}
className={className}
>
{generated ? t('generatedBadge') : t('receivedBadge')}
</Badge>
);
}

View File

@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
@@ -52,6 +53,7 @@ export function TaskCaseGroupHeader({
{t('importantBadge')}
</Badge>
) : null}
<LabCaseOriginBadge origin={caseGroup.origin} className="text-[10px]" />
</div>
{sentLabel ? (
<p className="text-[11px] text-text-muted">

View File

@@ -12,6 +12,8 @@ import {
labTaskStatusVariant,
} from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import {
@@ -153,6 +155,9 @@ export function TaskRow({
{t('importantBadge')}
</Badge>
) : null}
{flatMode && !exiting ? (
<LabCaseOriginBadge origin={task.origin} className="text-[10px]" />
) : null}
{flatMode && task.caseDueDate && !exiting ? (
<LabCaseDueDateBadge
dueDate={task.caseDueDate}
@@ -220,6 +225,7 @@ export function TaskRow({
viewerSide="LAB"
canPost
canToggleVisibility
clinicVisibility={!isLabGeneratedCase(task.origin)}
loadComments={async () => {
const r = await tasksApi.listComments(task.labCaseId);
return r.data;

View File

@@ -12,6 +12,7 @@ import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGro
import { TaskRow } from '@/components/ui/lab/TaskRow';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
import {
buildDefaultLocateParams,
DEFAULT_TASKS_VIEW,
@@ -77,6 +78,7 @@ export function TasksPage() {
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly);
const [originFilter, setOriginFilter] = useState(DEFAULT_TASKS_VIEW.origin);
const [prosthesisTypeCode, setProsthesisTypeCode] = useState(
DEFAULT_TASKS_VIEW.prosthesisTypeCode,
);
@@ -118,9 +120,10 @@ export function TasksPage() {
if (assignedToMe) params.assignedToMe = true;
if (overdueOnly) params.overdue = true;
if (unassignedOnly) params.unassignedOnly = true;
if (originFilter) params.origin = originFilter;
if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode;
return params;
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, prosthesisTypeCode, sortBy, sortDir]);
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, originFilter, prosthesisTypeCode, sortBy, sortDir]);
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
@@ -138,6 +141,7 @@ export function TasksPage() {
assignedToMe,
overdueOnly,
unassignedOnly,
origin: originFilter,
prosthesisTypeCode,
page,
highlightTaskId,
@@ -153,6 +157,7 @@ export function TasksPage() {
assignedToMe,
overdueOnly,
unassignedOnly,
originFilter,
prosthesisTypeCode,
page,
highlightTaskId,
@@ -166,6 +171,7 @@ export function TasksPage() {
if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly);
if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly);
if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly);
if (fromUrl.origin !== undefined) setOriginFilter(fromUrl.origin);
if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter);
if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode);
if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy);
@@ -273,6 +279,7 @@ export function TasksPage() {
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
setOriginFilter(DEFAULT_TASKS_VIEW.origin);
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
@@ -293,6 +300,7 @@ export function TasksPage() {
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
setOriginFilter(DEFAULT_TASKS_VIEW.origin);
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
@@ -440,7 +448,7 @@ export function TasksPage() {
placeholder={t('searchPlaceholder')}
/>
<div className="overflow-x-auto">
<div className="grid min-w-[44rem] grid-cols-4 gap-2">
<div className="grid min-w-[54rem] grid-cols-5 gap-2">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
@@ -456,6 +464,22 @@ export function TasksPage() {
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterOrigin')}</span>
<select
value={originFilter}
onChange={(e) =>
applyFilterChange(() =>
setOriginFilter(e.target.value as '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL'),
)
}
className={filterSelectClass}
>
<option value="">{t('filterOriginAll')}</option>
<option value="CLINIC_DISPATCH">{t('filterOriginReceived')}</option>
<option value="LAB_INTERNAL">{t('filterOriginGenerated')}</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
<select
@@ -581,6 +605,7 @@ export function TasksPage() {
viewerSide="LAB"
canPost
canToggleVisibility
clinicVisibility={!isLabGeneratedCase(caseGroup.origin)}
loadComments={async () => {
const r = await tasksApi.listComments(caseGroup.labCaseId);
return r.data;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

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}

View File

@@ -196,6 +196,7 @@ export interface ListLabTasksParams {
overdue?: boolean;
unassignedOnly?: boolean;
prosthesisTypeCode?: string;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
sentFrom?: string;
sentTo?: string;
stepCompleted?: string;
@@ -217,6 +218,7 @@ export interface LocateTaskPageParams {
overdue?: boolean;
unassignedOnly?: boolean;
prosthesisTypeCode?: string;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
stepCompleted?: string;
sortBy?: TaskSortField;
sortDir?: 'asc' | 'desc';
@@ -250,6 +252,7 @@ export interface LabTaskListItem {
lastStatusChangedBy: LabTaskUser | null;
createdAt: string;
caseSentAt: string | null;
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
clinic: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string };
}