bugfix/treatment-bugs #66
@@ -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**.
|
- **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.
|
- **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.
|
- **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 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.
|
- **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.
|
- **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 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`.
|
- **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`).
|
- **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.
|
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
|
||||||
|
|
||||||
Full map: `.cursor/skills/treatment-workspace/SKILL.md`
|
Full map: `.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)
|
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 |
|
| Stage | UI | When |
|
||||||
|-------|-----|------|
|
|-------|-----|------|
|
||||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
|
| **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.
|
- 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 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).
|
- 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.
|
- 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.
|
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`.
|
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
|
||||||
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
|
- 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 `<input type="checkbox">`) 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`.
|
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 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):**
|
**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). 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 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`).
|
- 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.
|
- **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.
|
- **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).
|
- **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):**
|
**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.
|
- 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.
|
||||||
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
|
- 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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
|
|||||||
|
|
||||||
const treatmentInclude = {
|
const treatmentInclude = {
|
||||||
patient: {
|
patient: {
|
||||||
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
|
select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
|
||||||
},
|
},
|
||||||
details: {
|
details: {
|
||||||
orderBy: [{ sortOrder: 'asc' as const }],
|
orderBy: [{ sortOrder: 'asc' as const }],
|
||||||
@@ -1366,6 +1366,8 @@ export class TreatmentsService {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isWalkIn: boolean;
|
isWalkIn: boolean;
|
||||||
|
mobile?: string | null;
|
||||||
|
email?: string | null;
|
||||||
};
|
};
|
||||||
details: Array<{
|
details: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1426,6 +1428,8 @@ export class TreatmentsService {
|
|||||||
firstName: treatment.patient.firstName,
|
firstName: treatment.patient.firstName,
|
||||||
lastName: treatment.patient.lastName,
|
lastName: treatment.patient.lastName,
|
||||||
isWalkIn: treatment.patient.isWalkIn,
|
isWalkIn: treatment.patient.isWalkIn,
|
||||||
|
mobile: treatment.patient.mobile ?? null,
|
||||||
|
email: treatment.patient.email ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
details: treatment.details.map((d) => this.mapDetail(d)),
|
details: treatment.details.map((d) => this.mapDetail(d)),
|
||||||
|
|||||||
@@ -683,7 +683,9 @@
|
|||||||
"selectedPatient": "Selected patient",
|
"selectedPatient": "Selected patient",
|
||||||
"purposeLabel": "Purpose:",
|
"purposeLabel": "Purpose:",
|
||||||
"loadingAppointments": "Loading appointments…",
|
"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?",
|
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
|
||||||
"errorChooseOrg": "Choose at least one active organization to send this case.",
|
"errorChooseOrg": "Choose at least one active organization to send this case.",
|
||||||
"successCaseSent": "Case sent to selected organizations.",
|
"successCaseSent": "Case sent to selected organizations.",
|
||||||
@@ -707,6 +709,8 @@
|
|||||||
"walkIn": "Walk-in",
|
"walkIn": "Walk-in",
|
||||||
"newTreatment": "New treatment",
|
"newTreatment": "New treatment",
|
||||||
"newTreatmentPatientPrompt": "Who is this visit for?",
|
"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.",
|
"walkInPickerHint": "No named patient — always available, no search needed.",
|
||||||
"errorCreateTreatment": "Could not create treatment.",
|
"errorCreateTreatment": "Could not create treatment.",
|
||||||
"deleteEmptyTreatment": "Delete empty treatment",
|
"deleteEmptyTreatment": "Delete empty treatment",
|
||||||
@@ -717,6 +721,7 @@
|
|||||||
"detailsTitle": "Treatment details",
|
"detailsTitle": "Treatment details",
|
||||||
"addDetail": "Add detail",
|
"addDetail": "Add detail",
|
||||||
"confirmRemoveDetail": "Remove this treatment 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}",
|
"removeDetailAria": "Remove detail {n}",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "sent",
|
"detailSentBadge": "sent",
|
||||||
|
|||||||
@@ -684,7 +684,9 @@
|
|||||||
"selectedPatient": "بیمار انتخاب شده",
|
"selectedPatient": "بیمار انتخاب شده",
|
||||||
"purposeLabel": "هدف:",
|
"purposeLabel": "هدف:",
|
||||||
"loadingAppointments": "در حال بارگذاری نوبتها...",
|
"loadingAppointments": "در حال بارگذاری نوبتها...",
|
||||||
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
|
"selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
|
||||||
|
"noTreatmentFoundTitle": "درمانی یافت نشد",
|
||||||
|
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. میتوانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
|
||||||
"confirmDiscard": "تغییرات ذخیرهنشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
|
"confirmDiscard": "تغییرات ذخیرهنشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
|
||||||
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
|
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
|
||||||
"successCaseSent": "پرونده به سازمانهای انتخاب شده ارسال شد.",
|
"successCaseSent": "پرونده به سازمانهای انتخاب شده ارسال شد.",
|
||||||
@@ -708,6 +710,8 @@
|
|||||||
"walkIn": "بدون نوبت (مراجع)",
|
"walkIn": "بدون نوبت (مراجع)",
|
||||||
"newTreatment": "درمان جدید",
|
"newTreatment": "درمان جدید",
|
||||||
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
|
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
|
||||||
|
"newTreatmentUseCurrent": "استفاده از {name}",
|
||||||
|
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
|
||||||
"walkInPickerHint": "بیمار نامدار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
|
"walkInPickerHint": "بیمار نامدار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
|
||||||
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
|
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
|
||||||
"deleteEmptyTreatment": "حذف درمان خالی",
|
"deleteEmptyTreatment": "حذف درمان خالی",
|
||||||
@@ -718,6 +722,7 @@
|
|||||||
"detailsTitle": "جزئیات درمان",
|
"detailsTitle": "جزئیات درمان",
|
||||||
"addDetail": "افزودن جزئیات",
|
"addDetail": "افزودن جزئیات",
|
||||||
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
||||||
|
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی میماند تا جزئیات جدیدی اضافه کنید. ادامه میدهید؟",
|
||||||
"removeDetailAria": "حذف جزئیات {n}",
|
"removeDetailAria": "حذف جزئیات {n}",
|
||||||
"detailLabel": "جزئیات {n}",
|
"detailLabel": "جزئیات {n}",
|
||||||
"detailSentBadge": "ارسالشده",
|
"detailSentBadge": "ارسالشده",
|
||||||
|
|||||||
@@ -683,7 +683,9 @@
|
|||||||
"selectedPatient": "Geselecteerde patiënt",
|
"selectedPatient": "Geselecteerde patiënt",
|
||||||
"purposeLabel": "Doel:",
|
"purposeLabel": "Doel:",
|
||||||
"loadingAppointments": "Afspraken laden...",
|
"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?",
|
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
|
||||||
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
|
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
|
||||||
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
|
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
|
||||||
@@ -707,6 +709,8 @@
|
|||||||
"walkIn": "Inloop",
|
"walkIn": "Inloop",
|
||||||
"newTreatment": "Nieuwe behandeling",
|
"newTreatment": "Nieuwe behandeling",
|
||||||
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
|
"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.",
|
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
|
||||||
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
|
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
|
||||||
"deleteEmptyTreatment": "Lege behandeling verwijderen",
|
"deleteEmptyTreatment": "Lege behandeling verwijderen",
|
||||||
@@ -717,6 +721,7 @@
|
|||||||
"detailsTitle": "Behandeldetails",
|
"detailsTitle": "Behandeldetails",
|
||||||
"addDetail": "Detail toevoegen",
|
"addDetail": "Detail toevoegen",
|
||||||
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
|
"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",
|
"removeDetailAria": "Detail {n} verwijderen",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "verzonden",
|
"detailSentBadge": "verzonden",
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ export type DayStripItem = {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
canDelete?: boolean;
|
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() ?? '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +1,60 @@
|
|||||||
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
|
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
|
||||||
|
|
||||||
export type LabDispatchDefaults = {
|
/** Chips under lab search — last destinations this clinic actually sent a case to. */
|
||||||
lastLabId: string | null;
|
export const MAX_RECENT_LABS = 3;
|
||||||
lastProsthesisByLab: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY: LabDispatchDefaults = {
|
|
||||||
lastLabId: null,
|
|
||||||
lastProsthesisByLab: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
function storageKey(clinicOrganizationId: string): string {
|
function storageKey(clinicOrganizationId: string): string {
|
||||||
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
|
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
|
function uniqueIds(ids: string[]): string[] {
|
||||||
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
|
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 {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
|
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
|
||||||
if (!raw) return EMPTY;
|
if (!raw) return [];
|
||||||
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
|
const parsed = JSON.parse(raw) as {
|
||||||
return {
|
recentLabIds?: unknown;
|
||||||
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
|
lastLabId?: unknown;
|
||||||
lastProsthesisByLab:
|
|
||||||
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
|
|
||||||
? parsed.lastProsthesisByLab
|
|
||||||
: {},
|
|
||||||
};
|
};
|
||||||
|
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 {
|
} catch {
|
||||||
return EMPTY;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
|
function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
|
window.localStorage.setItem(
|
||||||
|
storageKey(clinicOrganizationId),
|
||||||
|
JSON.stringify({ recentLabIds }),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore quota / private-mode failures.
|
// Ignore quota / private-mode failures.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
|
/** Record a lab after a successful send — suggestion chips only, never a form default. */
|
||||||
if (!clinicOrganizationId || !labId) return;
|
export function rememberRecentLab(
|
||||||
const current = loadLabDispatchDefaults(clinicOrganizationId);
|
|
||||||
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberLastProsthesisType(
|
|
||||||
clinicOrganizationId: string | null | undefined,
|
clinicOrganizationId: string | null | undefined,
|
||||||
labId: string,
|
labId: string,
|
||||||
prosthesisTypeCode: string,
|
|
||||||
): void {
|
): void {
|
||||||
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
|
if (!clinicOrganizationId || !labId) return;
|
||||||
const current = loadLabDispatchDefaults(clinicOrganizationId);
|
writeRecentLabIds(
|
||||||
writeDefaults(clinicOrganizationId, {
|
clinicOrganizationId,
|
||||||
...current,
|
uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ const TOOTH_NUMBER_GAP = 'mt-1';
|
|||||||
const REALISTIC_NUMBER_GAP = '2mm';
|
const REALISTIC_NUMBER_GAP = '2mm';
|
||||||
/** Tight interproximal gap between tooth columns. */
|
/** Tight interproximal gap between tooth columns. */
|
||||||
const TOOTH_GAP = 'gap-x-px';
|
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 {
|
function quadrantMirrored(fdi: FdiToothId): boolean {
|
||||||
const q = fdi[0];
|
const q = fdi[0];
|
||||||
@@ -285,40 +291,42 @@ export function FdiToothChart({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{renderEdge ? (
|
{renderEdge ? (
|
||||||
linkInteractive ? (
|
<div className={EDGE_MARK_ANCHOR}>
|
||||||
<button
|
{linkInteractive ? (
|
||||||
type="button"
|
<button
|
||||||
disabled={isDisabled}
|
type="button"
|
||||||
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
|
disabled={isDisabled}
|
||||||
aria-label={
|
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
|
||||||
linked
|
aria-label={
|
||||||
? t('toothUnlinkAria', { a: fdi, b: next! })
|
|
||||||
: t('toothLinkAria', { a: fdi, b: next! })
|
|
||||||
}
|
|
||||||
aria-pressed={linked}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleLink?.(fdi, next!);
|
|
||||||
}}
|
|
||||||
className={`
|
|
||||||
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
|
||||||
${
|
|
||||||
linked
|
linked
|
||||||
? 'border-primary bg-primary shadow-sm'
|
? t('toothUnlinkAria', { a: fdi, b: next! })
|
||||||
: 'border-primary bg-background-secondary hover:bg-primary/15'
|
: t('toothLinkAria', { a: fdi, b: next! })
|
||||||
}
|
}
|
||||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
aria-pressed={linked}
|
||||||
`}
|
onClick={(e) => {
|
||||||
/>
|
e.preventDefault();
|
||||||
) : (
|
e.stopPropagation();
|
||||||
<span
|
onToggleLink?.(fdi, next!);
|
||||||
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm"
|
}}
|
||||||
title={t('toothConnectedHint')}
|
className={`
|
||||||
aria-hidden
|
h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
|
||||||
/>
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||||
)
|
${
|
||||||
|
linked
|
||||||
|
? 'border-primary bg-primary shadow-sm'
|
||||||
|
: 'border-primary bg-background-secondary hover:bg-primary/15'
|
||||||
|
}
|
||||||
|
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
|
||||||
|
title={t('toothConnectedHint')}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
@@ -16,12 +16,6 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
|
|||||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||||
import { treatmentsApi } from '@/lib/api/treatments';
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import {
|
|
||||||
lastProsthesisTypeForLab,
|
|
||||||
loadLabDispatchDefaults,
|
|
||||||
rememberLastLab,
|
|
||||||
rememberLastProsthesisType,
|
|
||||||
} from '@/components/treatment/labDispatchDefaults';
|
|
||||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||||
@@ -34,7 +28,6 @@ interface LabCasesDispatchPanelProps {
|
|||||||
labCases: LabCaseDraft[];
|
labCases: LabCaseDraft[];
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
clinicOrganizationId?: string | null;
|
|
||||||
labCaseSummary?: PatientLabCaseSummary | null;
|
labCaseSummary?: PatientLabCaseSummary | null;
|
||||||
locale: string;
|
locale: string;
|
||||||
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
|
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
|
||||||
@@ -48,7 +41,6 @@ interface LabCasesDispatchPanelProps {
|
|||||||
organizationSearch: string;
|
organizationSearch: string;
|
||||||
onOrganizationSearchChange: (value: string) => void;
|
onOrganizationSearchChange: (value: string) => void;
|
||||||
recentOrganizationIds: string[];
|
recentOrganizationIds: string[];
|
||||||
onRecentOrganizationPick: (orgId: string) => void;
|
|
||||||
canInviteLab?: boolean;
|
canInviteLab?: boolean;
|
||||||
onInviteLab?: () => void;
|
onInviteLab?: () => void;
|
||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
@@ -124,7 +116,6 @@ export function LabCasesDispatchPanel({
|
|||||||
labCases,
|
labCases,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
clinicOrganizationId,
|
|
||||||
labCaseSummary,
|
labCaseSummary,
|
||||||
locale,
|
locale,
|
||||||
onLabCaseSummaryChange,
|
onLabCaseSummaryChange,
|
||||||
@@ -138,7 +129,6 @@ export function LabCasesDispatchPanel({
|
|||||||
organizationSearch,
|
organizationSearch,
|
||||||
onOrganizationSearchChange,
|
onOrganizationSearchChange,
|
||||||
recentOrganizationIds,
|
recentOrganizationIds,
|
||||||
onRecentOrganizationPick,
|
|
||||||
canInviteLab = false,
|
canInviteLab = false,
|
||||||
onInviteLab,
|
onInviteLab,
|
||||||
sendBusyId,
|
sendBusyId,
|
||||||
@@ -150,7 +140,6 @@ export function LabCasesDispatchPanel({
|
|||||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||||
const [pendingComment, setPendingComment] = useState('');
|
const [pendingComment, setPendingComment] = useState('');
|
||||||
const autoFilledCaseRef = useRef<string | null>(null);
|
|
||||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||||
|
|
||||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||||
@@ -168,7 +157,14 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
const activeLabCase =
|
const activeLabCase =
|
||||||
labCaseForActiveDetail ??
|
labCaseForActiveDetail ??
|
||||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
(activeLabCaseId
|
||||||
|
? labCases.find(
|
||||||
|
(lc) =>
|
||||||
|
lc.clientId === activeLabCaseId &&
|
||||||
|
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
|
||||||
|
)
|
||||||
|
: null) ??
|
||||||
|
null;
|
||||||
|
|
||||||
const sent = Boolean(activeLabCase?.sentAt);
|
const sent = Boolean(activeLabCase?.sentAt);
|
||||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||||
@@ -208,6 +204,7 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingComment('');
|
setPendingComment('');
|
||||||
|
setApplyAllProsthesis('');
|
||||||
}, [activeLabCase?.clientId]);
|
}, [activeLabCase?.clientId]);
|
||||||
|
|
||||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||||
@@ -226,45 +223,6 @@ export function LabCasesDispatchPanel({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
|
||||||
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
|
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
|
|
||||||
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
|
|
||||||
const lastLab = lastLabId
|
|
||||||
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
|
|
||||||
: undefined;
|
|
||||||
if (!lastLab) return;
|
|
||||||
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeLabCase || sent) return;
|
|
||||||
if (!activeLabCase.destinationOrganizationId) return;
|
|
||||||
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
|
|
||||||
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
|
|
||||||
if (autoFilledCaseRef.current === fillKey) return;
|
|
||||||
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
|
|
||||||
autoFilledCaseRef.current = fillKey;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastCode = lastProsthesisTypeForLab(
|
|
||||||
clinicOrganizationId,
|
|
||||||
activeLabCase.destinationOrganizationId,
|
|
||||||
);
|
|
||||||
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
|
|
||||||
autoFilledCaseRef.current = fillKey;
|
|
||||||
setApplyAllProsthesis(lastCode);
|
|
||||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
|
||||||
activeLabCase?.clientId,
|
|
||||||
activeLabCase?.destinationOrganizationId,
|
|
||||||
clinicOrganizationId,
|
|
||||||
prosthesisOptions,
|
|
||||||
prosthesisRows.length,
|
|
||||||
sent,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!activeDetail || !isLabDependentDetail) {
|
if (!activeDetail || !isLabDependentDetail) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -287,25 +245,11 @@ export function LabCasesDispatchPanel({
|
|||||||
]
|
]
|
||||||
: rest;
|
: rest;
|
||||||
updateActiveLabCase({ toothProsthesis: next });
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
|
|
||||||
rememberLastProsthesisType(
|
|
||||||
clinicOrganizationId,
|
|
||||||
activeLabCase.destinationOrganizationId,
|
|
||||||
prosthesisTypeCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProsthesisToAll(code: string) {
|
function applyProsthesisToAll(code: string) {
|
||||||
if (!activeLabCase || !code) return;
|
if (!activeLabCase || !code) return;
|
||||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
|
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
|
||||||
if (activeLabCase.destinationOrganizationId) {
|
|
||||||
rememberLastProsthesisType(
|
|
||||||
clinicOrganizationId,
|
|
||||||
activeLabCase.destinationOrganizationId,
|
|
||||||
code,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
||||||
@@ -317,13 +261,11 @@ export function LabCasesDispatchPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
||||||
autoFilledCaseRef.current = null;
|
|
||||||
updateActiveLabCase({
|
updateActiveLabCase({
|
||||||
destinationOrganizationId: org.id,
|
destinationOrganizationId: org.id,
|
||||||
toothProsthesis: [],
|
toothProsthesis: [],
|
||||||
});
|
});
|
||||||
setApplyAllProsthesis('');
|
setApplyAllProsthesis('');
|
||||||
rememberLastLab(clinicOrganizationId, org.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
||||||
@@ -463,10 +405,7 @@ export function LabCasesDispatchPanel({
|
|||||||
key={o.id}
|
key={o.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => handleSelectOrganization(o)}
|
||||||
handleSelectOrganization(o);
|
|
||||||
onRecentOrganizationPick(o.id);
|
|
||||||
}}
|
|
||||||
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
|
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{o.name}
|
{o.name}
|
||||||
@@ -615,26 +554,7 @@ export function LabCasesDispatchPanel({
|
|||||||
!prosthesisComplete
|
!prosthesisComplete
|
||||||
}
|
}
|
||||||
isLoading={sendBusyId === activeLabCase.clientId}
|
isLoading={sendBusyId === activeLabCase.clientId}
|
||||||
onClick={() => {
|
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||||
if (activeLabCase.destinationOrganizationId) {
|
|
||||||
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
|
|
||||||
const codes = [
|
|
||||||
...new Set(
|
|
||||||
activeLabCase.toothProsthesis
|
|
||||||
.map((tp) => tp.prosthesisTypeCode)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (codes.length === 1) {
|
|
||||||
rememberLastProsthesisType(
|
|
||||||
clinicOrganizationId,
|
|
||||||
activeLabCase.destinationOrganizationId,
|
|
||||||
codes[0],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return onSendLabCase(activeLabCase, pendingComment.trim());
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('sendToLab')}
|
{t('sendToLab')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AlertCircle } from 'lucide-react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
export function LabShipmentBlockedNotice() {
|
|
||||||
const t = useTranslations('treatment');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
|
|
||||||
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,19 +4,45 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
import type { Patient } from '@/types/patient';
|
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 {
|
interface NewTreatmentPatientPickerProps {
|
||||||
creating?: boolean;
|
creating?: boolean;
|
||||||
|
currentPatient?: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
mobile?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
} | null;
|
||||||
onSelectWalkIn: () => void | Promise<void>;
|
onSelectWalkIn: () => void | Promise<void>;
|
||||||
onSelectPatient: (patient: Patient) => void | Promise<void>;
|
onSelectPatient: (patient: Patient) => void | Promise<void>;
|
||||||
|
onSelectCurrentPatient?: () => void | Promise<void>;
|
||||||
onCancel: () => void;
|
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({
|
export function NewTreatmentPatientPicker({
|
||||||
creating = false,
|
creating = false,
|
||||||
|
currentPatient = null,
|
||||||
onSelectWalkIn,
|
onSelectWalkIn,
|
||||||
onSelectPatient,
|
onSelectPatient,
|
||||||
|
onSelectCurrentPatient,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: NewTreatmentPatientPickerProps) {
|
}: NewTreatmentPatientPickerProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
@@ -31,11 +57,25 @@ export function NewTreatmentPatientPicker({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={() => void onSelectWalkIn()}
|
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}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
|
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
|
||||||
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
|
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
|
||||||
</button>
|
</button>
|
||||||
|
{currentPatient && onSelectCurrentPatient ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={creating}
|
||||||
|
onClick={() => void onSelectCurrentPatient()}
|
||||||
|
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
|
||||||
|
className={pickerChoiceClass}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5 break-words">
|
||||||
|
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<PatientSearchCombobox
|
<PatientSearchCombobox
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
|
|||||||
@@ -203,12 +203,12 @@ export function TreatmentDetailsEditor({
|
|||||||
|
|
||||||
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
||||||
|
|
||||||
|
{activeDetail && showMissingTeethLabBlock ? (
|
||||||
|
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showFields && activeDetail ? (
|
{showFields && activeDetail ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{showMissingTeethLabBlock && (
|
|
||||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label={t('treatmentType')}
|
label={t('treatmentType')}
|
||||||
@@ -268,6 +268,8 @@ export function TreatmentDetailsEditor({
|
|||||||
|
|
||||||
{footer ? <div className="pt-1">{footer}</div> : null}
|
{footer ? <div className="pt-1">{footer}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : showFields && !activeDetail ? (
|
||||||
|
<p className="text-sm text-text-muted">{t('noDetails')}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
|||||||
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
|
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
|
||||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||||
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
|
||||||
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
|
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
|
||||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||||
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
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 { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { notificationsApi } from '@/lib/api/notifications';
|
import { notificationsApi } from '@/lib/api/notifications';
|
||||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||||
import type { DayStripItem } from '@/components/treatment/dayStrip';
|
import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
|
||||||
import {
|
import {
|
||||||
areDetailsPersistable,
|
areDetailsPersistable,
|
||||||
defaultTreatmentTypeForAppointment,
|
defaultTreatmentTypeForAppointment,
|
||||||
@@ -57,7 +56,11 @@ import {
|
|||||||
} from '@/components/treatment/toothSelectionGroups';
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||||
import { collectLabDispatchAttention } 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 { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||||
@@ -203,6 +206,7 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
|
|||||||
patientId: record.patientId,
|
patientId: record.patientId,
|
||||||
patientFirstName: record.patient.firstName,
|
patientFirstName: record.patient.firstName,
|
||||||
patientLastName: record.patient.lastName,
|
patientLastName: record.patient.lastName,
|
||||||
|
patientMobile: record.patient.mobile,
|
||||||
providerUserId: record.providerUserId,
|
providerUserId: record.providerUserId,
|
||||||
startAt: record.startAt,
|
startAt: record.startAt,
|
||||||
endAt: record.endAt,
|
endAt: record.endAt,
|
||||||
@@ -383,10 +387,12 @@ export function TreatmentWorkspace({
|
|||||||
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
|
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
|
||||||
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
|
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
|
||||||
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
|
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
|
||||||
const [searchedPatient, setSearchedPatient] = useState<Pick<
|
const [searchedPatient, setSearchedPatient] = useState<
|
||||||
Patient,
|
(Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
|
||||||
'id' | 'firstName' | 'lastName'
|
mobile?: string | null;
|
||||||
> | null>(null);
|
email?: string | null;
|
||||||
|
}) | null
|
||||||
|
>(null);
|
||||||
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
|
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
|
||||||
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
|
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
|
||||||
const [creatingStandalone, setCreatingStandalone] = useState(false);
|
const [creatingStandalone, setCreatingStandalone] = useState(false);
|
||||||
@@ -499,6 +505,8 @@ export function TreatmentWorkspace({
|
|||||||
id: selectedAppointment.patientId,
|
id: selectedAppointment.patientId,
|
||||||
firstName: selectedAppointment.patientFirstName,
|
firstName: selectedAppointment.patientFirstName,
|
||||||
lastName: selectedAppointment.patientLastName,
|
lastName: selectedAppointment.patientLastName,
|
||||||
|
mobile: selectedAppointment.patientMobile ?? null,
|
||||||
|
email: null,
|
||||||
purpose: selectedAppointment.purpose,
|
purpose: selectedAppointment.purpose,
|
||||||
isWalkIn: false,
|
isWalkIn: false,
|
||||||
};
|
};
|
||||||
@@ -509,6 +517,8 @@ export function TreatmentWorkspace({
|
|||||||
id: selectedStandalone.patientId,
|
id: selectedStandalone.patientId,
|
||||||
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
|
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
|
||||||
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
|
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
|
||||||
|
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
|
||||||
|
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
|
||||||
purpose: selectedStandalone.details[0]?.treatmentType,
|
purpose: selectedStandalone.details[0]?.treatmentType,
|
||||||
isWalkIn,
|
isWalkIn,
|
||||||
};
|
};
|
||||||
@@ -518,7 +528,9 @@ export function TreatmentWorkspace({
|
|||||||
id: searchedPatient.id,
|
id: searchedPatient.id,
|
||||||
firstName: searchedPatient.firstName,
|
firstName: searchedPatient.firstName,
|
||||||
lastName: searchedPatient.lastName,
|
lastName: searchedPatient.lastName,
|
||||||
purpose: undefined as string | undefined,
|
mobile: searchedPatient.mobile ?? null,
|
||||||
|
email: searchedPatient.email ?? null,
|
||||||
|
purpose: undefined,
|
||||||
isWalkIn: false,
|
isWalkIn: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -529,6 +541,23 @@ export function TreatmentWorkspace({
|
|||||||
const activePatientName = activePatient
|
const activePatientName = activePatient
|
||||||
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
|
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
|
||||||
: null;
|
: 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 unreadUpdatesCount = unreadLabCases.length;
|
||||||
const otherPatientsUnreadCount = useMemo(
|
const otherPatientsUnreadCount = useMemo(
|
||||||
@@ -600,8 +629,8 @@ export function TreatmentWorkspace({
|
|||||||
setStandaloneTreatments((prev) => {
|
setStandaloneTreatments((prev) => {
|
||||||
const current = prev.find((row) => row.id === selectedStandaloneId);
|
const current = prev.find((row) => row.id === selectedStandaloneId);
|
||||||
if (!current) return prev;
|
if (!current) return prev;
|
||||||
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
const prevColor = unscheduledStripColorCode(current.details);
|
||||||
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
const nextColor = unscheduledStripColorCode(nextDetails);
|
||||||
if (
|
if (
|
||||||
prevColor === nextColor &&
|
prevColor === nextColor &&
|
||||||
areUnscheduledDetailsStripDeletable(current.details) ===
|
areUnscheduledDetailsStripDeletable(current.details) ===
|
||||||
@@ -632,8 +661,7 @@ export function TreatmentWorkspace({
|
|||||||
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
|
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
|
||||||
? details
|
? details
|
||||||
: tr.details;
|
: tr.details;
|
||||||
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
|
const colorCode = unscheduledStripColorCode(sourceDetails);
|
||||||
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
|
|
||||||
return {
|
return {
|
||||||
kind: 'unscheduled' as const,
|
kind: 'unscheduled' as const,
|
||||||
id: tr.id,
|
id: tr.id,
|
||||||
@@ -735,7 +763,7 @@ export function TreatmentWorkspace({
|
|||||||
mapped.length > 0
|
mapped.length > 0
|
||||||
? mapped
|
? mapped
|
||||||
: options?.seedBlankIfEmpty
|
: options?.seedBlankIfEmpty
|
||||||
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
|
? [newDetail()]
|
||||||
: [];
|
: [];
|
||||||
setDetails(nextDetails);
|
setDetails(nextDetails);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
@@ -750,7 +778,7 @@ export function TreatmentWorkspace({
|
|||||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||||
setOrganizationSearch('');
|
setOrganizationSearch('');
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
}, [treatmentCatalog]);
|
}, []);
|
||||||
|
|
||||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||||
const connectedSelectedTeeth = useMemo(
|
const connectedSelectedTeeth = useMemo(
|
||||||
@@ -803,6 +831,7 @@ export function TreatmentWorkspace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowWholeTreatmentPlan(false);
|
setShowWholeTreatmentPlan(false);
|
||||||
rangeAnchorRef.current = null;
|
rangeAnchorRef.current = null;
|
||||||
|
setOrganizationSearch('');
|
||||||
const pending = pendingEntryStepRef.current;
|
const pending = pendingEntryStepRef.current;
|
||||||
pendingEntryStepRef.current = null;
|
pendingEntryStepRef.current = null;
|
||||||
setEntryStep(pending ?? 'treatment');
|
setEntryStep(pending ?? 'treatment');
|
||||||
@@ -910,11 +939,11 @@ export function TreatmentWorkspace({
|
|||||||
setLabDependentCodes(
|
setLabDependentCodes(
|
||||||
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
||||||
);
|
);
|
||||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
|
||||||
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
|
orgsResponse.data.some((o) => o.id === id && o.active),
|
||||||
setRecentOrganizationIds((prev) =>
|
);
|
||||||
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
|
if (recentIds.length > 0) {
|
||||||
);
|
setRecentOrganizationIds(recentIds);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -1549,24 +1578,51 @@ export function TreatmentWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ok = await flushDraftSave();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
setPatientSearchBusy(true);
|
setPatientSearchBusy(true);
|
||||||
setSearchedPatient({
|
|
||||||
id: patient.id,
|
|
||||||
firstName: patient.firstName,
|
|
||||||
lastName: patient.lastName,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
|
const stripAppointment = appointments.find((row) => row.patientId === patient.id);
|
||||||
const latest = response.data[0];
|
const stripStandalone = standaloneTreatments.find(
|
||||||
if (latest) {
|
(row) => row.patientId === patient.id && !row.patient?.isWalkIn,
|
||||||
const ok = await loadTreatmentIntoWorkspace(latest);
|
);
|
||||||
if (!ok) {
|
if (stripAppointment || stripStandalone) {
|
||||||
setSearchedPatient(null);
|
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;
|
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) {
|
} catch (error: unknown) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||||
setSearchedPatient(null);
|
setSearchedPatient(null);
|
||||||
@@ -1580,8 +1636,11 @@ export function TreatmentWorkspace({
|
|||||||
selectedStandalone?.patientId,
|
selectedStandalone?.patientId,
|
||||||
searchedPatient?.id,
|
searchedPatient?.id,
|
||||||
hasLiveContext,
|
hasLiveContext,
|
||||||
|
flushDraftSave,
|
||||||
|
appointments,
|
||||||
|
standaloneTreatments,
|
||||||
|
resetToLiveContext,
|
||||||
loadTreatmentIntoWorkspace,
|
loadTreatmentIntoWorkspace,
|
||||||
refreshHistory,
|
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
tErrors,
|
tErrors,
|
||||||
@@ -1883,7 +1942,9 @@ export function TreatmentWorkspace({
|
|||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
const target = details[idx];
|
const target = details[idx];
|
||||||
if (!target || isDetailLocked(target)) return;
|
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 nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||||
const nextActive =
|
const nextActive =
|
||||||
@@ -1959,14 +2020,9 @@ export function TreatmentWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
|
||||||
const lastLabStillActive = lastLabId
|
|
||||||
? orgs.some((o) => o.id === lastLabId && o.active)
|
|
||||||
: false;
|
|
||||||
const next: LabCaseDraft = {
|
const next: LabCaseDraft = {
|
||||||
...newLabCaseDraft(),
|
...newLabCaseDraft(),
|
||||||
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
||||||
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
|
|
||||||
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
||||||
};
|
};
|
||||||
const updatedLabCases = [...cleaned, next];
|
const updatedLabCases = [...cleaned, next];
|
||||||
@@ -1982,11 +2038,9 @@ export function TreatmentWorkspace({
|
|||||||
}, [
|
}, [
|
||||||
activeDetailId,
|
activeDetailId,
|
||||||
canEditTreatmentForDay,
|
canEditTreatmentForDay,
|
||||||
currentOrganization?.id,
|
|
||||||
details,
|
details,
|
||||||
labCaseDrafts,
|
labCaseDrafts,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
orgs,
|
|
||||||
persistDraft,
|
persistDraft,
|
||||||
persistLabCases,
|
persistLabCases,
|
||||||
selectedAppointment,
|
selectedAppointment,
|
||||||
@@ -2106,8 +2160,8 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
setRecentOrganizationIds((prev) => {
|
setRecentOrganizationIds((prev) => {
|
||||||
const orgId = labCase.destinationOrganizationId!;
|
const orgId = labCase.destinationOrganizationId!;
|
||||||
rememberLastLab(currentOrganization?.id, orgId);
|
rememberRecentLab(currentOrganization?.id, orgId);
|
||||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
|
||||||
});
|
});
|
||||||
showSuccess(t('successCaseSent'));
|
showSuccess(t('successCaseSent'));
|
||||||
notifyTabBadgesChanged();
|
notifyTabBadgesChanged();
|
||||||
@@ -2148,11 +2202,24 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1">
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
<div className="min-w-0 shrink-0">
|
||||||
{!canEdit ? (
|
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||||
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
|
{!canEdit ? (
|
||||||
) : null}
|
<p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<PatientSearchCombobox
|
||||||
|
search={patientSearch}
|
||||||
|
onSearchChange={setPatientSearch}
|
||||||
|
patients={patientSearchResults}
|
||||||
|
loading={patientSearchLoading || patientSearchBusy}
|
||||||
|
onSelectPatient={handleSelectSearchedPatient}
|
||||||
|
placeholder={tPatients('searchPlaceholder')}
|
||||||
|
emptyResultsMessage={tPatients('noResults')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AppointmentsStrip
|
<AppointmentsStrip
|
||||||
@@ -2183,15 +2250,6 @@ export function TreatmentWorkspace({
|
|||||||
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
|
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
|
||||||
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
||||||
<div className="surface-card p-3 space-y-3">
|
<div className="surface-card p-3 space-y-3">
|
||||||
<PatientSearchCombobox
|
|
||||||
search={patientSearch}
|
|
||||||
onSearchChange={setPatientSearch}
|
|
||||||
patients={patientSearchResults}
|
|
||||||
loading={patientSearchLoading || patientSearchBusy}
|
|
||||||
onSelectPatient={handleSelectSearchedPatient}
|
|
||||||
placeholder={tPatients('searchPlaceholder')}
|
|
||||||
emptyResultsMessage={tPatients('noResults')}
|
|
||||||
/>
|
|
||||||
{canEdit && !isViewingPastDay ? (
|
{canEdit && !isViewingPastDay ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -2206,10 +2264,16 @@ export function TreatmentWorkspace({
|
|||||||
{newTreatmentPickerOpen ? (
|
{newTreatmentPickerOpen ? (
|
||||||
<NewTreatmentPatientPicker
|
<NewTreatmentPatientPicker
|
||||||
creating={creatingStandalone}
|
creating={creatingStandalone}
|
||||||
|
currentPatient={namedActivePatient}
|
||||||
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
|
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
|
||||||
onSelectPatient={(patient) =>
|
onSelectPatient={(patient) =>
|
||||||
createStandaloneTreatment({ patientId: patient.id })
|
createStandaloneTreatment({ patientId: patient.id })
|
||||||
}
|
}
|
||||||
|
onSelectCurrentPatient={
|
||||||
|
namedActivePatient
|
||||||
|
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onCancel={() => setNewTreatmentPickerOpen(false)}
|
onCancel={() => setNewTreatmentPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2217,8 +2281,14 @@ export function TreatmentWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activePatient ? (
|
{activePatient ? (
|
||||||
<div className="space-y-0.5 border-t border-border/60 pt-3">
|
<div
|
||||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
|
className={`space-y-0.5 ${
|
||||||
|
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||||
|
{t('selectedPatient')}
|
||||||
|
</p>
|
||||||
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
|
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
|
||||||
{activePatient.purpose ? (
|
{activePatient.purpose ? (
|
||||||
<p className="text-[11px] text-text-secondary">
|
<p className="text-[11px] text-text-secondary">
|
||||||
@@ -2230,7 +2300,11 @@ export function TreatmentWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
|
<p
|
||||||
|
className={`text-sm text-text-muted ${
|
||||||
|
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -2333,6 +2407,24 @@ export function TreatmentWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 min-w-0 w-full">
|
<div className="space-y-3 min-w-0 w-full">
|
||||||
|
{showSearchedPatientLoading ? (
|
||||||
|
<div className="surface-card p-6">
|
||||||
|
<p className="text-sm text-text-muted">{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
) : showNoTreatmentFound ? (
|
||||||
|
<div className="surface-card w-full p-6 space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
|
{t('noTreatmentFoundTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{t('noTreatmentFoundBody', {
|
||||||
|
name: activePatientName ?? '',
|
||||||
|
action: t('newTreatment'),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TreatmentDetailsEditor
|
<TreatmentDetailsEditor
|
||||||
details={details}
|
details={details}
|
||||||
activeDetailId={activeDetailId}
|
activeDetailId={activeDetailId}
|
||||||
@@ -2346,9 +2438,14 @@ export function TreatmentWorkspace({
|
|||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||||
onAddDetail={() => {
|
onAddDetail={() => {
|
||||||
const next = newDetail(
|
const seedFromAppointment =
|
||||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
details.length === 0 && selectedAppointment
|
||||||
);
|
? defaultTreatmentTypeForAppointment(
|
||||||
|
selectedAppointment.purpose,
|
||||||
|
treatmentCatalog,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const next = newDetail(seedFromAppointment);
|
||||||
setDetails((prev) => [...prev, next]);
|
setDetails((prev) => [...prev, next]);
|
||||||
setActiveDetailId(next.clientId);
|
setActiveDetailId(next.clientId);
|
||||||
setEntryStep('treatment');
|
setEntryStep('treatment');
|
||||||
@@ -2587,7 +2684,6 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
{entryStep === 'lab' ? (
|
{entryStep === 'lab' ? (
|
||||||
<div ref={labPanelRef} className="space-y-3">
|
<div ref={labPanelRef} className="space-y-3">
|
||||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
|
||||||
{showLabDispatchPanel ? (
|
{showLabDispatchPanel ? (
|
||||||
<LabCasesDispatchPanel
|
<LabCasesDispatchPanel
|
||||||
details={details}
|
details={details}
|
||||||
@@ -2595,7 +2691,6 @@ export function TreatmentWorkspace({
|
|||||||
labCases={labCaseDrafts}
|
labCases={labCaseDrafts}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
clinicOrganizationId={currentOrganization?.id}
|
|
||||||
labCaseSummary={activeLabCaseSummary}
|
labCaseSummary={activeLabCaseSummary}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||||
@@ -2613,33 +2708,21 @@ export function TreatmentWorkspace({
|
|||||||
organizationSearch={organizationSearch}
|
organizationSearch={organizationSearch}
|
||||||
onOrganizationSearchChange={setOrganizationSearch}
|
onOrganizationSearchChange={setOrganizationSearch}
|
||||||
recentOrganizationIds={recentOrganizationIds}
|
recentOrganizationIds={recentOrganizationIds}
|
||||||
onRecentOrganizationPick={(orgId) => {
|
|
||||||
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}
|
sendBusyId={sendBusyId}
|
||||||
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
||||||
onCommentError={showError}
|
onCommentError={showError}
|
||||||
canInviteLab={canAccessOrganizations}
|
canInviteLab={canAccessOrganizations}
|
||||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : showLabShipmentBlocked ? null : (
|
||||||
<p className="text-sm text-text-muted surface-card p-4">
|
<p className="text-sm text-text-muted surface-card p-4">
|
||||||
{t('entryStepLabUnavailable')}
|
{t('entryStepLabUnavailable')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface TreatmentAppointment {
|
|||||||
patientId: string;
|
patientId: string;
|
||||||
patientFirstName: string;
|
patientFirstName: string;
|
||||||
patientLastName: string;
|
patientLastName: string;
|
||||||
|
patientMobile?: string | null;
|
||||||
providerUserId: string;
|
providerUserId: string;
|
||||||
startAt: string;
|
startAt: string;
|
||||||
endAt: string;
|
endAt: string;
|
||||||
@@ -139,6 +140,8 @@ export interface PastTreatment {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isWalkIn: boolean;
|
isWalkIn: boolean;
|
||||||
|
mobile?: string | null;
|
||||||
|
email?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
details: PastTreatmentDetail[];
|
details: PastTreatmentDetail[];
|
||||||
labCases: PastLabCase[];
|
labCases: PastLabCase[];
|
||||||
|
|||||||
Reference in New Issue
Block a user