From de46da216fdf01e88260143142bc0812b17fa65a Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 17:49:38 +0330 Subject: [PATCH 1/6] bugfix: FDI tooth chart is now compatible with RTL direction. --- .../components/ui/treatment/FdiToothChart.tsx | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index d61b1b3..eba19a3 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -24,6 +24,12 @@ const TOOTH_NUMBER_GAP = 'mt-1'; const REALISTIC_NUMBER_GAP = '2mm'; /** Tight interproximal gap between tooth columns. */ const TOOTH_GAP = 'gap-x-px'; +/** + * Centers a mark on the inline-end edge of a tooth column (between this tooth + * and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL. + */ +const EDGE_MARK_ANCHOR = + 'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center'; function quadrantMirrored(fdi: FdiToothId): boolean { const q = fdi[0]; @@ -285,40 +291,42 @@ export function FdiToothChart({ /> ) : null} {renderEdge ? ( - linkInteractive ? ( - diff --git a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx deleted file mode 100644 index 71eddee..0000000 --- a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { AlertCircle } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -export function LabShipmentBlockedNotice() { - const t = useTranslations('treatment'); - - return ( -
-
- -
-

{t('labShipmentBlockedTitle')}

-

{t('labShipmentBlockedBody')}

-
-
-
- ); -} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 884ffdc..dd7cedd 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps { canEdit: boolean; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; - onAddDetail: () => void; + onAddDetail: (initialType?: string) => void; onRemoveDetail?: (detailClientId: string) => void; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise; onRemoveAttachment?: (attachmentId: string) => void; @@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({ type="button" variant="primary" disabled={!canEdit || disabled} - onClick={onAddDetail} + onClick={() => onAddDetail()} fullWidth className="sm:w-auto shrink-0" > @@ -203,12 +203,12 @@ export function TreatmentDetailsEditor({ {stepper && activeDetail ?
{stepper}
: null} + {activeDetail && showMissingTeethLabBlock ? ( +

{t('labShipmentBlockedBody')}

+ ) : null} + {showFields && activeDetail ? (
- {showMissingTeethLabBlock && ( -

{t('labShipmentBlockedBody')}

- )} -
{footer}
: null}
+ ) : showFields && !activeDetail ? ( +
+ { + const nextType = e.target.value; + if (nextType) onAddDetail(nextType); + }} + disabled={!canEdit || disabled} + > + + {treatmentCatalog.map((entry, index) => ( + + ))} + + {chart ? ( +
+
+

{chartLockMessage}

+
+
{chart}
+
+ ) : chartLockMessage ? ( +

{chartLockMessage}

+ ) : null} +
) : null} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 4d60d18..26c9517 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -15,7 +15,6 @@ 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'; import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel'; import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; @@ -57,7 +56,11 @@ 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 { + loadRecentLabIds, + MAX_RECENT_LABS, + rememberRecentLab, +} 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'; @@ -735,7 +738,7 @@ export function TreatmentWorkspace({ mapped.length > 0 ? mapped : options?.seedBlankIfEmpty - ? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))] + ? [newDetail()] : []; setDetails(nextDetails); setActiveDetailId((prev) => { @@ -750,7 +753,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( @@ -803,6 +806,7 @@ export function TreatmentWorkspace({ useEffect(() => { setShowWholeTreatmentPlan(false); rangeAnchorRef.current = null; + setOrganizationSearch(''); const pending = pendingEntryStepRef.current; pendingEntryStepRef.current = null; setEntryStep(pending ?? 'treatment'); @@ -910,11 +914,11 @@ 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), - ); + const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) => + orgsResponse.data.some((o) => o.id === id && o.active), + ); + if (recentIds.length > 0) { + setRecentOrganizationIds(recentIds); } } catch (error: unknown) { if (!cancelled) { @@ -1959,14 +1963,9 @@ 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]; @@ -1982,11 +1981,9 @@ export function TreatmentWorkspace({ }, [ activeDetailId, canEditTreatmentForDay, - currentOrganization?.id, details, labCaseDrafts, labDependentCodes, - orgs, persistDraft, persistLabCases, selectedAppointment, @@ -2106,8 +2103,8 @@ export function TreatmentWorkspace({ setRecentOrganizationIds((prev) => { const orgId = labCase.destinationOrganizationId!; - rememberLastLab(currentOrganization?.id, orgId); - return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10); + rememberRecentLab(currentOrganization?.id, orgId); + return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS); }); showSuccess(t('successCaseSent')); notifyTabBadgesChanged(); @@ -2345,10 +2342,8 @@ export function TreatmentWorkspace({ canEdit={canEdit} saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} - onAddDetail={() => { - const next = newDetail( - defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), - ); + onAddDetail={(initialType) => { + const next = newDetail(initialType); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment'); @@ -2587,7 +2582,6 @@ export function TreatmentWorkspace({ {entryStep === 'lab' ? (
- {showLabShipmentBlocked ? : null} {showLabDispatchPanel ? ( { - setLabCaseDrafts((prev) => { - const targetId = - activeLabCaseId ?? - prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId) - ?.clientId; - if (!targetId) return prev; - return prev.map((lc) => - lc.clientId === targetId && !lc.sentAt - ? { ...lc, destinationOrganizationId: orgId } - : lc, - ); - }); - }} sendBusyId={sendBusyId} onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)} onCommentError={showError} canInviteLab={canAccessOrganizations} onInviteLab={() => router.push('/organizations?action=invite-lab')} /> - ) : ( + ) : showLabShipmentBlocked ? null : (

{t('entryStepLabUnavailable')}

From 50725b7b3fc4c73add67b4c9c78abffeed1cd796 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 19:36:37 +0330 Subject: [PATCH 3/6] bugfix: no-detail treatment ui made some confusion. it is improved. at least to a degree. --- .cursor/rules/treatment-workspace.mdc | 6 ++-- .cursor/skills/treatment-workspace/SKILL.md | 2 +- AGENTS.md | 2 +- frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + frontend/src/components/treatment/dayStrip.ts | 7 ++++ .../ui/treatment/TreatmentDetailsEditor.tsx | 33 ++----------------- .../ui/treatment/TreatmentWorkspace.tsx | 17 +++++----- 9 files changed, 27 insertions(+), 43 deletions(-) diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 54c8891..3c3cc53 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -10,14 +10,14 @@ alwaysApply: false - **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**. - **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. - **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change. -- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. +- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. -- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. +- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. -- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment. Empty plan (`[]`) still shows type-first overlay (not a blank Add-only card). +- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment. - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). - **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 426b784..dabe36d 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add. +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) diff --git a/AGENTS.md b/AGENTS.md index 3f2a573..bf72e37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ frontend/src/ **Treatment edit / details (quick ref):** - Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. -- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). +- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments. - **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ba37dc5..2450819 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -717,6 +717,7 @@ "detailsTitle": "Treatment details", "addDetail": "Add detail", "confirmRemoveDetail": "Remove this treatment detail?", + "confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?", "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 755adda..179ba2d 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -718,6 +718,7 @@ "detailsTitle": "جزئیات درمان", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", + "confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 6aae598..167ac9e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -717,6 +717,7 @@ "detailsTitle": "Behandeldetails", "addDetail": "Detail toevoegen", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", + "confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?", "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", diff --git a/frontend/src/components/treatment/dayStrip.ts b/frontend/src/components/treatment/dayStrip.ts index 41707a2..1e8b3b0 100644 --- a/frontend/src/components/treatment/dayStrip.ts +++ b/frontend/src/components/treatment/dayStrip.ts @@ -12,3 +12,10 @@ export type DayStripItem = { subtitle: string; canDelete?: boolean; }; + +/** Unscheduled card banner: first line’s type only. Empty / missing type → no color. */ +export function unscheduledStripColorCode( + details: readonly { treatmentType?: string | null }[], +): string { + return details[0]?.treatmentType?.trim() ?? ''; +} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index dd7cedd..89bcae0 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps { canEdit: boolean; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; - onAddDetail: (initialType?: string) => void; + onAddDetail: () => void; onRemoveDetail?: (detailClientId: string) => void; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise; onRemoveAttachment?: (attachmentId: string) => void; @@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({ type="button" variant="primary" disabled={!canEdit || disabled} - onClick={() => onAddDetail()} + onClick={onAddDetail} fullWidth className="sm:w-auto shrink-0" > @@ -269,34 +269,7 @@ export function TreatmentDetailsEditor({ {footer ?
{footer}
: null}
) : showFields && !activeDetail ? ( -
- { - const nextType = e.target.value; - if (nextType) onAddDetail(nextType); - }} - disabled={!canEdit || disabled} - > - - {treatmentCatalog.map((entry, index) => ( - - ))} - - {chart ? ( -
-
-

{chartLockMessage}

-
-
{chart}
-
- ) : chartLockMessage ? ( -

{chartLockMessage}

- ) : null} -
+

{t('noDetails')}

) : null} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 26c9517..b893458 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -32,7 +32,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 { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip'; import { areDetailsPersistable, defaultTreatmentTypeForAppointment, @@ -603,8 +603,8 @@ export function TreatmentWorkspace({ 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() ?? ''; + const prevColor = unscheduledStripColorCode(current.details); + const nextColor = unscheduledStripColorCode(nextDetails); if ( prevColor === nextColor && areUnscheduledDetailsStripDeletable(current.details) === @@ -635,8 +635,7 @@ export function TreatmentWorkspace({ const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current ? details : tr.details; - const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim())); - const colorCode = typedDetail?.treatmentType?.trim() ?? ''; + const colorCode = unscheduledStripColorCode(sourceDetails); return { kind: 'unscheduled' as const, id: tr.id, @@ -1887,7 +1886,9 @@ export function TreatmentWorkspace({ if (idx < 0) return; const target = details[idx]; if (!target || isDetailLocked(target)) return; - if (!window.confirm(t('confirmRemoveDetail'))) return; + if (!window.confirm( + details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'), + )) return; const nextDetails = details.filter((d) => d.clientId !== detailClientId); const nextActive = @@ -2342,8 +2343,8 @@ export function TreatmentWorkspace({ canEdit={canEdit} saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} - onAddDetail={(initialType) => { - const next = newDetail(initialType); + onAddDetail={() => { + const next = newDetail(); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment'); From b1405b22b1b9e455f3bbca3a0dd75eb022bf6195 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 20:11:43 +0330 Subject: [PATCH 4/6] improvement: patient search moved to the top of treatment feature. --- .cursor/rules/treatment-workspace.mdc | 1 + .cursor/skills/treatment-workspace/SKILL.md | 4 +- AGENTS.md | 2 +- .../modules/treatments/treatments.service.ts | 6 +- frontend/messages/en.json | 4 +- frontend/messages/fa.json | 4 +- frontend/messages/nl.json | 4 +- .../treatment/NewTreatmentPatientPicker.tsx | 42 +++++++++- .../ui/treatment/TreatmentWorkspace.tsx | 84 ++++++++++++++----- frontend/src/types/treatment.ts | 3 + 10 files changed, 124 insertions(+), 30 deletions(-) diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 3c3cc53..e4c4412 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -14,6 +14,7 @@ alwaysApply: false - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. - **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields. +- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index dabe36d..55e8974 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) @@ -201,7 +201,7 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv -Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in). +Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks Walk-in, the current named patient card, or search). Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. diff --git a/AGENTS.md b/AGENTS.md index bf72e37..7ab5676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ frontend/src/ **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. **Treatment edit / details (quick ref):** -- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments. diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index f370657..21be004 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl const treatmentInclude = { patient: { - select: { id: true, firstName: true, lastName: true, isWalkIn: true }, + select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true }, }, details: { orderBy: [{ sortOrder: 'asc' as const }], @@ -1366,6 +1366,8 @@ export class TreatmentsService { firstName: string; lastName: string; isWalkIn: boolean; + mobile?: string | null; + email?: string | null; }; details: Array<{ id: string; @@ -1426,6 +1428,8 @@ export class TreatmentsService { firstName: treatment.patient.firstName, lastName: treatment.patient.lastName, isWalkIn: treatment.patient.isWalkIn, + mobile: treatment.patient.mobile ?? null, + email: treatment.patient.email ?? null, } : null, details: treatment.details.map((d) => this.mapDetail(d)), diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2450819..7412cea 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -683,7 +683,7 @@ "selectedPatient": "Selected patient", "purposeLabel": "Purpose:", "loadingAppointments": "Loading appointments…", - "selectDayWithAppointment": "Select a day with at least one appointment.", + "selectDayWithAppointment": "Search for a patient or select a visit from the day strip.", "confirmDiscard": "You have unsaved changes. Discard them and continue?", "errorChooseOrg": "Choose at least one active organization to send this case.", "successCaseSent": "Case sent to selected organizations.", @@ -707,6 +707,8 @@ "walkIn": "Walk-in", "newTreatment": "New treatment", "newTreatmentPatientPrompt": "Who is this visit for?", + "newTreatmentUseCurrent": "Use {name}", + "newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.", "walkInPickerHint": "No named patient — always available, no search needed.", "errorCreateTreatment": "Could not create treatment.", "deleteEmptyTreatment": "Delete empty treatment", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 179ba2d..9316b36 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -684,7 +684,7 @@ "selectedPatient": "بیمار انتخاب شده", "purposeLabel": "هدف:", "loadingAppointments": "در حال بارگذاری نوبت‌ها...", - "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", @@ -708,6 +708,8 @@ "walkIn": "بدون نوبت (مراجع)", "newTreatment": "درمان جدید", "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "newTreatmentUseCurrent": "استفاده از {name}", + "newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.", "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.", "deleteEmptyTreatment": "حذف درمان خالی", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 167ac9e..46b7491 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -683,7 +683,7 @@ "selectedPatient": "Geselecteerde patiënt", "purposeLabel": "Doel:", "loadingAppointments": "Afspraken laden...", - "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", + "selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.", "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", "successCaseSent": "Case verzonden naar geselecteerde organisaties.", @@ -707,6 +707,8 @@ "walkIn": "Inloop", "newTreatment": "Nieuwe behandeling", "newTreatmentPatientPrompt": "Voor wie is dit bezoek?", + "newTreatmentUseCurrent": "{name} gebruiken", + "newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.", "walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.", "errorCreateTreatment": "Behandeling aanmaken is mislukt.", "deleteEmptyTreatment": "Lege behandeling verwijderen", diff --git a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx index 0be070d..f732bab 100644 --- a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx +++ b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx @@ -4,19 +4,45 @@ 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 { formatMobileForDisplay } from '@/lib/phone'; import type { Patient } from '@/types/patient'; +const pickerChoiceClass = + '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'; + interface NewTreatmentPatientPickerProps { creating?: boolean; + currentPatient?: { + id: string; + displayName: string; + mobile?: string | null; + email?: string | null; + } | null; onSelectWalkIn: () => void | Promise; onSelectPatient: (patient: Patient) => void | Promise; + onSelectCurrentPatient?: () => void | Promise; onCancel: () => void; } +function contactLine( + patient: { mobile?: string | null; email?: string | null }, + fallback: string, +): string { + const mobile = patient.mobile?.trim() + ? formatMobileForDisplay(patient.mobile.trim()) + : ''; + if (mobile) return mobile; + const email = patient.email?.trim(); + if (email) return email; + return fallback; +} + export function NewTreatmentPatientPicker({ creating = false, + currentPatient = null, onSelectWalkIn, onSelectPatient, + onSelectCurrentPatient, onCancel, }: NewTreatmentPatientPickerProps) { const t = useTranslations('treatment'); @@ -31,11 +57,25 @@ export function NewTreatmentPatientPicker({ 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" + className={pickerChoiceClass} >

{t('walkIn')}

{t('walkInPickerHint')}

+ {currentPatient && onSelectCurrentPatient ? ( + + ) : null} ('patient'); const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState(null); - const [searchedPatient, setSearchedPatient] = useState | null>(null); + const [searchedPatient, setSearchedPatient] = useState< + (Pick & { + mobile?: string | null; + email?: string | null; + }) | null + >(null); const [patientSearchBusy, setPatientSearchBusy] = useState(false); const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false); const [creatingStandalone, setCreatingStandalone] = useState(false); @@ -502,6 +505,8 @@ export function TreatmentWorkspace({ id: selectedAppointment.patientId, firstName: selectedAppointment.patientFirstName, lastName: selectedAppointment.patientLastName, + mobile: selectedAppointment.patientMobile ?? null, + email: null, purpose: selectedAppointment.purpose, isWalkIn: false, }; @@ -512,6 +517,8 @@ export function TreatmentWorkspace({ id: selectedStandalone.patientId, firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''), lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''), + mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null), + email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null), purpose: selectedStandalone.details[0]?.treatmentType, isWalkIn, }; @@ -521,7 +528,9 @@ export function TreatmentWorkspace({ id: searchedPatient.id, firstName: searchedPatient.firstName, lastName: searchedPatient.lastName, - purpose: undefined as string | undefined, + mobile: searchedPatient.mobile ?? null, + email: searchedPatient.email ?? null, + purpose: undefined, isWalkIn: false, }; } @@ -532,6 +541,15 @@ export function TreatmentWorkspace({ const activePatientName = activePatient ? `${activePatient.firstName} ${activePatient.lastName}`.trim() : null; + const namedActivePatient = + activePatient && !activePatient.isWalkIn && activePatient.id + ? { + id: activePatient.id, + displayName: activePatientName ?? '', + mobile: activePatient.mobile ?? null, + email: activePatient.email ?? null, + } + : null; const unreadUpdatesCount = unreadLabCases.length; const otherPatientsUnreadCount = useMemo( @@ -1557,6 +1575,8 @@ export function TreatmentWorkspace({ id: patient.id, firstName: patient.firstName, lastName: patient.lastName, + mobile: patient.mobile, + email: patient.email, }); try { const response = await treatmentsApi.listPatientHistory(patient.id, 1); @@ -2146,11 +2166,24 @@ export function TreatmentWorkspace({ return (
-
-

{t('title')}

- {!canEdit ? ( -

{t('subtitleReadOnly')}

- ) : null} +
+
+

{t('title')}

+ {!canEdit ? ( +

{t('subtitleReadOnly')}

+ ) : null} +
+
+ +
- {canEdit && !isViewingPastDay ? (
From 8d34def0016ad1980f1725d2f02122867171d857 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 20:33:11 +0330 Subject: [PATCH 6/6] improvement: the last flow improved a little. --- .cursor/skills/treatment-workspace/SKILL.md | 2 +- AGENTS.md | 2 +- .../src/components/ui/treatment/TreatmentWorkspace.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 932ab50..c0ba28b 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -45,7 +45,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet - Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. - Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch. -- Detail type may differ from appointment purpose. Purpose only seeds the **first** line of an empty draft — **Add detail** starts with an empty type. +- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line. - Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). - Live draft is **not** duplicated in the left rail preview; preview is for history browse only. diff --git a/AGENTS.md b/AGENTS.md index 4cb937a..ee313a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ frontend/src/ **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add. - New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected. -- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty draft; **Add detail** starts with an empty type. +- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 586a146..0d22bba 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2438,7 +2438,14 @@ export function TreatmentWorkspace({ saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} onAddDetail={() => { - const next = newDetail(); + const seedFromAppointment = + details.length === 0 && selectedAppointment + ? defaultTreatmentTypeForAppointment( + selectedAppointment.purpose, + treatmentCatalog, + ) + : undefined; + const next = newDetail(seedFromAppointment); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment');