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');