diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 20b55b2..dbc5ecd 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -10,16 +10,17 @@ 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. +- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **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`. -- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. +- **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. +- **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. Full map: `.cursor/skills/treatment-workspace/SKILL.md` diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 4c7eb56..c0ba28b 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`. 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) @@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet | Stage | UI | When | |-------|-----|------| | **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | -| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. | +| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. | - 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 defaults new details). +- 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. @@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. -- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`). +- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card. - `handleAddLabCase` shows toast with `labShipmentBlockedBody`. - Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth). @@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days: -`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. +`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new detail’s lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. @@ -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 with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search). Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index 22b7c2e..211f33a 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -5,7 +5,7 @@ # Repository Variables (Settings → Actions → Variables): # REGISTRY_HOST Docker registry host:port (no http/https). Same PC as Gitea → 127.0.0.1:3000 # REGISTRY_OWNER Gitea user or org that owns the packages -# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash) +# PUBLIC_BASE_URL URL users sopen in browser, e.g. http://178.131.50.201:8088 (no trailing slash) # DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets # # Optional: diff --git a/AGENTS.md b/AGENTS.md index 89aa04a..8236b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,8 @@ 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. -- **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`). +- 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). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **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. - **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. @@ -53,8 +53,9 @@ frontend/src/ - **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator). **Treatment lab rules (quick ref):** -- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. -- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. +- 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 **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/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 ba37dc5..65b9760 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -683,7 +683,9 @@ "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.", + "noTreatmentFoundTitle": "No treatment found", + "noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.", "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 +709,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", @@ -717,6 +721,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..6909975 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -684,7 +684,9 @@ "selectedPatient": "بیمار انتخاب شده", "purposeLabel": "هدف:", "loadingAppointments": "در حال بارگذاری نوبت‌ها...", - "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.", + "noTreatmentFoundTitle": "درمانی یافت نشد", + "noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. می‌توانید با دکمه {action} در نوار کناری یکی اضافه کنید.", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", @@ -708,6 +710,8 @@ "walkIn": "بدون نوبت (مراجع)", "newTreatment": "درمان جدید", "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "newTreatmentUseCurrent": "استفاده از {name}", + "newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.", "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.", "deleteEmptyTreatment": "حذف درمان خالی", @@ -718,6 +722,7 @@ "detailsTitle": "جزئیات درمان", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", + "confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 6aae598..a8f2ffb 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -683,7 +683,9 @@ "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.", + "noTreatmentFoundTitle": "Geen behandeling gevonden", + "noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.", "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 +709,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", @@ -717,6 +721,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/treatment/labDispatchDefaults.ts b/frontend/src/components/treatment/labDispatchDefaults.ts index 1dc40b0..6ca710d 100644 --- a/frontend/src/components/treatment/labDispatchDefaults.ts +++ b/frontend/src/components/treatment/labDispatchDefaults.ts @@ -1,73 +1,60 @@ const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.'; -export type LabDispatchDefaults = { - lastLabId: string | null; - lastProsthesisByLab: Record; -}; - -const EMPTY: LabDispatchDefaults = { - lastLabId: null, - lastProsthesisByLab: {}, -}; +/** Chips under lab search — last destinations this clinic actually sent a case to. */ +export const MAX_RECENT_LABS = 3; function storageKey(clinicOrganizationId: string): string { return `${STORAGE_PREFIX}${clinicOrganizationId}`; } -export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults { - if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY; +function uniqueIds(ids: string[]): string[] { + const out: string[] = []; + for (const id of ids) { + if (id && !out.includes(id)) out.push(id); + } + return out; +} + +export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] { + if (!clinicOrganizationId || typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(storageKey(clinicOrganizationId)); - if (!raw) return EMPTY; - const parsed = JSON.parse(raw) as Partial; - return { - lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null, - lastProsthesisByLab: - parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object' - ? parsed.lastProsthesisByLab - : {}, + if (!raw) return []; + const parsed = JSON.parse(raw) as { + recentLabIds?: unknown; + lastLabId?: unknown; }; + const fromList = Array.isArray(parsed.recentLabIds) + ? parsed.recentLabIds.filter((id): id is string => typeof id === 'string') + : []; + // Older builds stored a single lastLabId used as an auto-selected default. + const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : []; + return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS); } catch { - return EMPTY; + return []; } } -function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void { +function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void { if (typeof window === 'undefined') return; try { - window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next)); + window.localStorage.setItem( + storageKey(clinicOrganizationId), + JSON.stringify({ recentLabIds }), + ); } catch { // Ignore quota / private-mode failures. } } -export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void { - if (!clinicOrganizationId || !labId) return; - const current = loadLabDispatchDefaults(clinicOrganizationId); - writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId }); -} - -export function rememberLastProsthesisType( +/** Record a lab after a successful send — suggestion chips only, never a form default. */ +export function rememberRecentLab( clinicOrganizationId: string | null | undefined, labId: string, - prosthesisTypeCode: string, ): void { - if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return; - const current = loadLabDispatchDefaults(clinicOrganizationId); - writeDefaults(clinicOrganizationId, { - ...current, - lastLabId: labId, - lastProsthesisByLab: { - ...current.lastProsthesisByLab, - [labId]: prosthesisTypeCode, - }, - }); -} - -export function lastProsthesisTypeForLab( - clinicOrganizationId: string | null | undefined, - labId: string | null | undefined, -): string | null { - if (!clinicOrganizationId || !labId) return null; - return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null; + if (!clinicOrganizationId || !labId) return; + writeRecentLabIds( + clinicOrganizationId, + uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS), + ); } 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/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} {stepper} : null} + {activeDetail && showMissingTeethLabBlock ? ( +

{t('labShipmentBlockedBody')}

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

{t('labShipmentBlockedBody')}

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

{t('noDetails')}

) : null} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 4d60d18..0d22bba 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'; @@ -33,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, @@ -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'; @@ -203,6 +206,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment { patientId: record.patientId, patientFirstName: record.patient.firstName, patientLastName: record.patient.lastName, + patientMobile: record.patient.mobile, providerUserId: record.providerUserId, startAt: record.startAt, endAt: record.endAt, @@ -383,10 +387,12 @@ export function TreatmentWorkspace({ const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false); const [labCasesScope, setLabCasesScope] = useState('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); @@ -499,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, }; @@ -509,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, }; @@ -518,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, }; } @@ -529,6 +541,23 @@ 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 searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext; + const showSearchedPatientLoading = + searchedWithoutLiveVisit && (patientSearchBusy || historyLoading); + const showNoTreatmentFound = + searchedWithoutLiveVisit && + !patientSearchBusy && + !historyLoading && + history.length === 0; const unreadUpdatesCount = unreadLabCases.length; const otherPatientsUnreadCount = useMemo( @@ -600,8 +629,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) === @@ -632,8 +661,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, @@ -735,7 +763,7 @@ export function TreatmentWorkspace({ mapped.length > 0 ? mapped : options?.seedBlankIfEmpty - ? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))] + ? [newDetail()] : []; setDetails(nextDetails); setActiveDetailId((prev) => { @@ -750,7 +778,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 +831,7 @@ export function TreatmentWorkspace({ useEffect(() => { setShowWholeTreatmentPlan(false); rangeAnchorRef.current = null; + setOrganizationSearch(''); const pending = pendingEntryStepRef.current; pendingEntryStepRef.current = null; setEntryStep(pending ?? 'treatment'); @@ -910,11 +939,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) { @@ -1549,24 +1578,51 @@ export function TreatmentWorkspace({ return; } + const ok = await flushDraftSave(); + if (!ok) return; + setPatientSearchBusy(true); - setSearchedPatient({ - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - }); try { - const response = await treatmentsApi.listPatientHistory(patient.id, 1); - const latest = response.data[0]; - if (latest) { - const ok = await loadTreatmentIntoWorkspace(latest); - if (!ok) { - setSearchedPatient(null); + const stripAppointment = appointments.find((row) => row.patientId === patient.id); + const stripStandalone = standaloneTreatments.find( + (row) => row.patientId === patient.id && !row.patient?.isWalkIn, + ); + if (stripAppointment || stripStandalone) { + draftHydratingRef.current = true; + resetToLiveContext(); + setSearchedPatient(null); + setNewTreatmentPickerOpen(false); + setSelectionLocked(true); + if (stripAppointment) { + setSelectedAppointmentId(stripAppointment.id); + setSelectedStandaloneId(null); + } else if (stripStandalone) { + setSelectedAppointmentId(null); + setSelectedStandaloneId(stripStandalone.id); } return; } - await refreshHistory(patient.id); + setSearchedPatient({ + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + mobile: patient.mobile, + email: patient.email, + }); + resetToLiveContext(); + setNewTreatmentPickerOpen(false); + setSelectedAppointmentId(null); + setSelectedStandaloneId(null); + setSelectionLocked(true); + setHistory([]); + setHistoryLoading(true); + + const response = await treatmentsApi.listPatientHistory(patient.id, 1); + const latest = response.data[0]; + if (latest) { + await loadTreatmentIntoWorkspace(latest); + } } catch (error: unknown) { showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); setSearchedPatient(null); @@ -1580,8 +1636,11 @@ export function TreatmentWorkspace({ selectedStandalone?.patientId, searchedPatient?.id, hasLiveContext, + flushDraftSave, + appointments, + standaloneTreatments, + resetToLiveContext, loadTreatmentIntoWorkspace, - refreshHistory, showError, t, tErrors, @@ -1883,7 +1942,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 = @@ -1959,14 +2020,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 +2038,9 @@ export function TreatmentWorkspace({ }, [ activeDetailId, canEditTreatmentForDay, - currentOrganization?.id, details, labCaseDrafts, labDependentCodes, - orgs, persistDraft, persistLabCases, selectedAppointment, @@ -2106,8 +2160,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(); @@ -2148,11 +2202,24 @@ export function TreatmentWorkspace({ return (
-
-

{t('title')}

- {!canEdit ? ( -

{t('subtitleReadOnly')}

- ) : null} +
+
+

{t('title')}

+ {!canEdit ? ( +

{t('subtitleReadOnly')}

+ ) : null} +
+
+ +
- {canEdit && !isViewingPastDay ? (
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index 16432ac..2c3b1c1 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -38,6 +38,7 @@ export interface TreatmentAppointment { patientId: string; patientFirstName: string; patientLastName: string; + patientMobile?: string | null; providerUserId: string; startAt: string; endAt: string; @@ -139,6 +140,8 @@ export interface PastTreatment { firstName: string; lastName: string; isWalkIn: boolean; + mobile?: string | null; + email?: string | null; } | null; details: PastTreatmentDetail[]; labCases: PastLabCase[];