Compare commits
5 Commits
68fc9d5d6d
...
0740493384
| Author | SHA1 | Date | |
|---|---|---|---|
| 0740493384 | |||
| 4b2349228a | |||
| 58676f702e | |||
| 1b63bfff00 | |||
| d2ad1fd7b6 |
@@ -9,4 +9,6 @@ alwaysApply: false
|
|||||||
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
||||||
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
|
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
|
||||||
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
|
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
|
||||||
|
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
|
||||||
|
- **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`).
|
||||||
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.
|
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.
|
||||||
|
|||||||
@@ -60,5 +60,6 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork
|
|||||||
| Filter or inline `<select>` | `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` |
|
| Filter or inline `<select>` | `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` |
|
||||||
| Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` |
|
| Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` |
|
||||||
| Desktop data table | `Table` (`ui/shared/Table.tsx`) — logical alignment only |
|
| Desktop data table | `Table` (`ui/shared/Table.tsx`) — logical alignment only |
|
||||||
|
| API action button | `Button` — return the Promise from `onClick` (`() => doThing()` not `() => void doThing()`); auto-disables until settle. List/icon mutations: `useAsyncAction` / `useAsyncActionById` |
|
||||||
|
|
||||||
Styles for `.form-select` chevrons live in `styles/globals.css`. Do not duplicate chevron icons on raw selects.
|
Styles for `.form-select` chevrons live in `styles/globals.css`. Do not duplicate chevron icons on raw selects.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ alwaysApply: false
|
|||||||
|
|
||||||
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
||||||
- **Current draft** preview: heading “Current draft”; no load button while editing live.
|
- **Current draft** preview: heading “Current draft”; no load button while editing live.
|
||||||
- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail. Appointment purpose does not gate type or Next.
|
- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. Detail-chip switches reset to Teeth unless `pendingEntryStepRef` requests Lab (shipments rail open).
|
||||||
- **Tooth groups:** Shift+same-arch range = connected; click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change.
|
- **Tooth groups:** Shift+same-arch range = connected bridge (linked dots, one shipment row); overlapping Shift ranges merge by union. Plain click = add/remove singles; click in a bridge cuts that tooth out as a selected single (end → remainder stays connected; middle → two bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send.
|
||||||
|
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
|
||||||
|
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
|
||||||
- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step.
|
- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Use when adding or changing user-visible dates/times/numbers, RTL layout, or loc
|
|||||||
- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts`
|
- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts`
|
||||||
- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts`
|
- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts`
|
||||||
- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR)
|
- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR)
|
||||||
- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker)
|
- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker); positioned via `useAnchoredPanelPosition` (fixed + viewport clamp / flip) so RTL/LTR edge fields stay on-screen
|
||||||
- **Do not** add native `<input type="date">` — one component for all locales
|
- **Do not** add native `<input type="date">` — one component for all locales
|
||||||
|
|
||||||
**Select chevron**
|
**Select chevron**
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|
|||||||
|
|
||||||
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
|
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
|
||||||
|
|
||||||
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments section via `LabCaseCommentsPanel` + token API adapters. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
|
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
|
||||||
|
|
||||||
## Auth redirect (logged out → login → back)
|
## Auth redirect (logged out → login → back)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
|
|||||||
## List behavior
|
## List behavior
|
||||||
|
|
||||||
- **Default sort:** `sentAt` desc (newest first).
|
- **Default sort:** `sentAt` desc (newest first).
|
||||||
|
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
|
||||||
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
|
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
|
||||||
- **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching).
|
- **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching).
|
||||||
|
- **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination).
|
||||||
|
|
||||||
## Filters (`GET /cases`)
|
## Filters (`GET /cases`)
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task
|
|||||||
## Detail panel
|
## Detail panel
|
||||||
|
|
||||||
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
|
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
|
||||||
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes
|
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls.
|
||||||
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
||||||
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker
|
|||||||
|
|
||||||
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
||||||
|
|
||||||
**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`).
|
**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). Shared `LabCaseCommentsPanel` with `viewerSide="LAB"` (newest-first, logical start/end chat align).
|
||||||
|
|
||||||
## Prosthesis colors
|
## Prosthesis colors
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
## APIs
|
## APIs
|
||||||
|
|
||||||
- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`)
|
- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`)
|
||||||
- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task)
|
- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task). Completing **`intraoral_scan`** also completes every other `intraoral_scan` task in the same case (case-scoped; catalog first step for all prosthesis types).
|
||||||
- `GET /tasks/filter-options` — clinics + workflow steps (localized)
|
- `GET /tasks/filter-options` — clinics + workflow steps (localized)
|
||||||
- `GET /tasks/locate-page` — page number for a task in the sorted filtered list
|
- `GET /tasks/locate-page` — page number for a task in the sorted filtered list
|
||||||
- `GET /cases/assignable-staff` — staff eligible for task assignment
|
- `GET /cases/assignable-staff` — staff eligible for task assignment
|
||||||
|
|||||||
@@ -40,12 +40,13 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx
|
|||||||
|
|
||||||
| Step | UI | Notes |
|
| Step | UI | Notes |
|
||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| **Teeth** | `FdiToothChart` | Shift+click range; connected dots |
|
| **Teeth** | `FdiToothChart` | Shift+click connected bridge (union if overlap); click cuts tooth from bridge as selected single |
|
||||||
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / notes / attachments — **no delete button** |
|
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** |
|
||||||
| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`) |
|
| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. |
|
||||||
|
|
||||||
- Detail type may differ from appointment purpose (purpose only defaults new details).
|
- Detail type may differ from appointment purpose (purpose only defaults new details).
|
||||||
- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content.
|
- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content.
|
||||||
|
- Switching `activeDetailId` resets the wizard to **Teeth**, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). That ref prevents the reset effect from overwriting Lab on the first click.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -53,12 +54,14 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail; lab tasks group by `selectionGroupId`.
|
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group).
|
||||||
|
|
||||||
- **Plain click:** add/remove single; if tooth is in a **connected** group → drop the whole span and keep **only** that tooth as a single (no peel / no connected+single pair for the same span).
|
- **Plain click:** add/remove single; if tooth is in a **connected** bridge → cut it out as a selected **single** (second click deselects). End of bridge → remainder stays connected (≥2) or demotes to single. Middle → left and right become separate groups. Never a 1-tooth connected (no lone dots).
|
||||||
- **Shift+click:** inclusive same-arch range between prior anchor and target; replaces overlapping groups. Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
|
- **Shift+click:** inclusive same-arch range → one **connected** bridge; if the range overlaps existing bridges, **union** them into one bridge. Non-overlapping bridges stay separate.
|
||||||
|
- Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
|
||||||
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
||||||
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
||||||
|
- Lab case comments: shared `LabCaseCommentsPanel` — newest-first; **sent** = `justify-start` / `text-start`, **received** = `justify-end` / `text-end` (RTL-safe); require `viewerSide: 'LAB' | 'CLINIC'`. Used in Cases, Tasks, share focus, and Treatment (`DetailLabCaseCommentsSection`). Composer: `h-9` input + primary send / visibility buttons (white icons; Send mirrored in RTL).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -121,12 +124,11 @@ Saved lab-dependent detail with **no teeth** can autosave but **cannot** create
|
|||||||
|
|
||||||
## Lab case comments on details
|
## Lab case comments on details
|
||||||
|
|
||||||
|
Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.
|
||||||
|
|
||||||
|
- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
|
||||||
Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed):
|
- Sent / in progress: live composer until tasks complete (`canPostComments`).
|
||||||
|
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
|
||||||
- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`.
|
|
||||||
- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints.
|
|
||||||
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
AGENTS.md
15
AGENTS.md
@@ -45,22 +45,23 @@ frontend/src/
|
|||||||
**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.
|
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form.
|
||||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||||
- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis). Detail chips ≠ wizard chrome (chips stay bordered tabs; stepper is numbered rail).
|
- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail).
|
||||||
- **Tooth selection:** Shift+click same-arch range = connected group; plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint).
|
- **Tooth selection:** Shift+click same-arch range = connected bridge (one lab row); overlapping Shift bridges merge by union. Plain click add/remove singles; click in a bridge cuts that tooth out as a selected single (ends keep one bridge; middle splits into two). Never a 1-tooth connected (no lone dots). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
|
||||||
|
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile).
|
||||||
|
|
||||||
**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** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
|
||||||
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
|
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
|
||||||
- **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).
|
- **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 entry wizard **Lab** step.
|
||||||
- **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).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
||||||
|
|
||||||
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
|
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
|
||||||
|
|
||||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
**Lab case share link (quick ref):**
|
**Lab case share link (quick ref):**
|
||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- The tooth_selection_groups migration tried to drop
|
||||||
|
-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key",
|
||||||
|
-- but PostgreSQL truncated/renamed that index to
|
||||||
|
-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key"
|
||||||
|
-- (see 20260710150528). The old unique index (without selectionGroupId)
|
||||||
|
-- therefore remained and blocks multiple selection groups that share
|
||||||
|
-- the same prosthesis type + stepOrder.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key";
|
||||||
|
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key";
|
||||||
|
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_s";
|
||||||
|
|
||||||
|
-- Ensure the correct unique index exists (idempotent for DBs that already have it).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key"
|
||||||
|
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Defensive cleanup for stage/prod (and any DB where the stale unique index
|
||||||
|
-- survived under a truncated/renamed Postgres identifier).
|
||||||
|
--
|
||||||
|
-- History:
|
||||||
|
-- 20260707130000 created unique (labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder)
|
||||||
|
-- → PG truncates the index name to 63 chars (...prosthesisTypeCode_s)
|
||||||
|
-- 20260710150528 renamed that truncated name to ...prosthesisTypeCo_key
|
||||||
|
-- 20260716120000 dropped the *untruncated* name (no-op) and added the correct
|
||||||
|
-- unique that includes selectionGroupId — leaving the stale unique in place
|
||||||
|
-- 20260717123000 drops known stale names; this migration also drops by definition
|
||||||
|
-- so any leftover old unique is removed regardless of identifier length/rename.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'lab_case_tasks'
|
||||||
|
AND indexdef ILIKE '%UNIQUE%'
|
||||||
|
AND indexdef ILIKE '%prosthesisTypeCode%'
|
||||||
|
AND indexdef ILIKE '%stepOrder%'
|
||||||
|
AND indexdef ILIKE '%treatmentDetailId%'
|
||||||
|
AND indexdef NOT ILIKE '%selectionGroupId%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Correct unique (includes selectionGroupId). Idempotent if already present.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key"
|
||||||
|
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder");
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Restore merge-by-prosthesis uniqueness for lab case tasks.
|
||||||
|
-- Product rule: all teeth on a detail that share the same prosthesis type become
|
||||||
|
-- one task set (Scan/Design/…). selectionGroupId remains on the table for UI
|
||||||
|
-- but is not part of the unique key.
|
||||||
|
--
|
||||||
|
-- Drop the selectionGroupId-inclusive unique (by known names + by definition).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename = 'lab_case_tasks'
|
||||||
|
AND indexdef ILIKE '%UNIQUE%'
|
||||||
|
AND indexdef ILIKE '%selectionGroupId%'
|
||||||
|
AND indexdef ILIKE '%prosthesisTypeCode%'
|
||||||
|
AND indexdef ILIKE '%stepOrder%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key";
|
||||||
|
|
||||||
|
-- Collapse rows that were split by selectionGroupId but share the merge key
|
||||||
|
-- (status events cascade via FK). Kept row may need regen for full teeth[]; new sends are correct.
|
||||||
|
DELETE FROM "lab_case_tasks" AS a
|
||||||
|
USING "lab_case_tasks" AS b
|
||||||
|
WHERE a."id" > b."id"
|
||||||
|
AND a."labCaseId" = b."labCaseId"
|
||||||
|
AND a."treatmentDetailId" = b."treatmentDetailId"
|
||||||
|
AND a."prosthesisTypeCode" = b."prosthesisTypeCode"
|
||||||
|
AND a."stepOrder" = b."stepOrder";
|
||||||
|
|
||||||
|
-- Short name stays under Postgres 63-char identifier limit.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"
|
||||||
|
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder");
|
||||||
@@ -379,7 +379,7 @@ model LabCaseTask {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([labCaseId, treatmentDetailId, selectionGroupId, prosthesisTypeCode, stepOrder])
|
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key")
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
@@index([assigneeUserId])
|
@@index([assigneeUserId])
|
||||||
@@map("lab_case_tasks")
|
@@map("lab_case_tasks")
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('generateLabCaseTasks', () => {
|
|||||||
expect(stepCodes).toContain('printer_resin');
|
expect(stepCodes).toContain('printer_resin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => {
|
it('merges teeth with the same prosthesis type across selection groups', async () => {
|
||||||
const pfmSteps = stepsFromSeed('pfm_crown');
|
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||||
const { tx, created } = buildMockTx({
|
const { tx, created } = buildMockTx({
|
||||||
toothProsthesisRows: [
|
toothProsthesisRows: [
|
||||||
@@ -198,21 +198,13 @@ describe('generateLabCaseTasks', () => {
|
|||||||
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const count = await generateLabCaseTasks(tx as never, 'lab-case-split', 'en');
|
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
|
||||||
expect(count).toBe(pfmSteps.length * 2);
|
expect(count).toBe(pfmSteps.length);
|
||||||
const rows = created as Array<{
|
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
|
||||||
teeth: string[];
|
expect(rows).toHaveLength(pfmSteps.length);
|
||||||
selectionGroupId: string;
|
expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe(
|
||||||
prosthesisTypeCode: string;
|
|
||||||
}>;
|
|
||||||
const connected = rows.filter((r) => r.selectionGroupId === 'connected-1');
|
|
||||||
const single = rows.filter((r) => r.selectionGroupId === 'single-21');
|
|
||||||
expect(connected).toHaveLength(pfmSteps.length);
|
|
||||||
expect(single).toHaveLength(pfmSteps.length);
|
|
||||||
expect(connected.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(
|
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips generation when tasks already exist', async () => {
|
it('skips generation when tasks already exist', async () => {
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export async function generateLabCaseTasks(
|
|||||||
|
|
||||||
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||||
|
|
||||||
// Group by selection group + prosthesis type so connected spans stay one task set,
|
// Merge all teeth that share the same prosthesis type on a detail into one
|
||||||
// and separate singles stay separate even with the same prosthesis type.
|
// task set (pre–selection-group behavior). selectionGroupId is stored for
|
||||||
|
// UI/history only — not part of the grouping key.
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -72,10 +73,8 @@ export async function generateLabCaseTasks(
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const row of toothProsthesisRows) {
|
for (const row of toothProsthesisRows) {
|
||||||
const selectionGroupId =
|
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
|
||||||
row.selectionGroupId?.trim() ||
|
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
||||||
fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode);
|
|
||||||
const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`;
|
|
||||||
const group = groups.get(key) ?? {
|
const group = groups.get(key) ?? {
|
||||||
treatmentDetailId: row.treatmentDetailId,
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
treatmentType: row.detail.treatmentType,
|
treatmentType: row.detail.treatmentType,
|
||||||
@@ -83,6 +82,10 @@ export async function generateLabCaseTasks(
|
|||||||
selectionGroupId,
|
selectionGroupId,
|
||||||
teeth: [],
|
teeth: [],
|
||||||
};
|
};
|
||||||
|
// Keep first non-empty selectionGroupId for persistence; grouping ignores it.
|
||||||
|
if (!group.selectionGroupId && selectionGroupId) {
|
||||||
|
group.selectionGroupId = selectionGroupId;
|
||||||
|
}
|
||||||
group.teeth.push(row.tooth);
|
group.teeth.push(row.tooth);
|
||||||
groups.set(key, group);
|
groups.set(key, group);
|
||||||
}
|
}
|
||||||
@@ -121,24 +124,6 @@ export async function generateLabCaseTasks(
|
|||||||
return taskRows.length;
|
return taskRows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackGroupIdForTooth(
|
|
||||||
toothSelectionGroups: unknown,
|
|
||||||
tooth: string,
|
|
||||||
prosthesisTypeCode: string,
|
|
||||||
): string {
|
|
||||||
if (Array.isArray(toothSelectionGroups)) {
|
|
||||||
for (const row of toothSelectionGroups) {
|
|
||||||
if (!row || typeof row !== 'object') continue;
|
|
||||||
const rec = row as { groupId?: unknown; teeth?: unknown };
|
|
||||||
if (typeof rec.groupId !== 'string') continue;
|
|
||||||
if (Array.isArray(rec.teeth) && rec.teeth.includes(tooth)) {
|
|
||||||
return rec.groupId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `legacy-${prosthesisTypeCode}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTeeth(teeth: string[]): string[] {
|
function sortTeeth(teeth: string[]): string[] {
|
||||||
return [...new Set(teeth)].sort((a, b) => {
|
return [...new Set(teeth)].sort((a, b) => {
|
||||||
const na = Number(a);
|
const na = Number(a);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const taskListInclude = {
|
|||||||
},
|
},
|
||||||
} satisfies Prisma.LabCaseTaskInclude;
|
} satisfies Prisma.LabCaseTaskInclude;
|
||||||
|
|
||||||
|
/** Completing this step completes every matching task in the same case (catalog: first step of all prosthesis types). */
|
||||||
|
const CASE_SCOPED_SCAN_STEP_CODE = 'intraoral_scan';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TasksService {
|
export class TasksService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -238,12 +241,13 @@ export class TasksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.$transaction(async (tx) => {
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
const result = await tx.labCaseTask.update({
|
const result = await tx.labCaseTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: {
|
data: {
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
lastStatusChangedByUserId: actorUserId,
|
lastStatusChangedByUserId: actorUserId,
|
||||||
lastStatusChangedAt: new Date(),
|
lastStatusChangedAt: now,
|
||||||
},
|
},
|
||||||
include: taskListInclude,
|
include: taskListInclude,
|
||||||
});
|
});
|
||||||
@@ -259,13 +263,58 @@ export class TasksService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cascadedScanTaskIds: string[] = [];
|
||||||
|
if (
|
||||||
|
dto.status === LabTaskStatus.COMPLETED &&
|
||||||
|
task.status !== LabTaskStatus.COMPLETED &&
|
||||||
|
task.workflowStepCode === CASE_SCOPED_SCAN_STEP_CODE
|
||||||
|
) {
|
||||||
|
const siblingScans = await tx.labCaseTask.findMany({
|
||||||
|
where: {
|
||||||
|
labCaseId: task.labCaseId,
|
||||||
|
workflowStepCode: CASE_SCOPED_SCAN_STEP_CODE,
|
||||||
|
status: { not: LabTaskStatus.COMPLETED },
|
||||||
|
id: { not: taskId },
|
||||||
|
},
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sibling of siblingScans) {
|
||||||
|
await tx.labCaseTask.update({
|
||||||
|
where: { id: sibling.id },
|
||||||
|
data: {
|
||||||
|
status: LabTaskStatus.COMPLETED,
|
||||||
|
lastStatusChangedByUserId: actorUserId,
|
||||||
|
lastStatusChangedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.labCaseTaskStatusEvent.create({
|
||||||
|
data: {
|
||||||
|
taskId: sibling.id,
|
||||||
|
fromStatus: sibling.status,
|
||||||
|
toStatus: LabTaskStatus.COMPLETED,
|
||||||
|
changedByUserId: actorUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cascadedScanTaskIds.push(sibling.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||||
await this.labCaseActivity.record(
|
await this.labCaseActivity.record(
|
||||||
{
|
{
|
||||||
labCaseId: task.labCaseId,
|
labCaseId: task.labCaseId,
|
||||||
type: LabCaseActivityType.TASK_COMPLETED,
|
type: LabCaseActivityType.TASK_COMPLETED,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { taskId },
|
payload: {
|
||||||
|
taskId,
|
||||||
|
...(cascadedScanTaskIds.length > 0
|
||||||
|
? {
|
||||||
|
cascadedTaskIds: cascadedScanTaskIds,
|
||||||
|
caseScopedStep: CASE_SCOPED_SCAN_STEP_CODE,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
||||||
"loadingSchedule": "Loading schedule…",
|
"loadingSchedule": "Loading schedule…",
|
||||||
"infoPastViewOnly": "Past appointments are view-only.",
|
"infoPastViewOnly": "Past appointments are view-only.",
|
||||||
|
"infoEditBlockedHasTreatment": "This appointment cannot be edited because a treatment is linked to it.",
|
||||||
"infoSelectPatient": "Select a patient before booking.",
|
"infoSelectPatient": "Select a patient before booking.",
|
||||||
"errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.",
|
"errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.",
|
||||||
"successUpdated": "Appointment updated.",
|
"successUpdated": "Appointment updated.",
|
||||||
@@ -668,8 +669,8 @@
|
|||||||
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
||||||
"addCase": "Add case",
|
"addCase": "Add case",
|
||||||
"caseLabel": "Case {n}",
|
"caseLabel": "Case {n}",
|
||||||
"comments": "Comments",
|
"comments": "Notes",
|
||||||
"commentsPlaceholder": "Write clinical notes for this case…",
|
"commentsPlaceholder": "Write clinical notes for this detail…",
|
||||||
"treatmentType": "Treatment type",
|
"treatmentType": "Treatment type",
|
||||||
"treatmentTypePlaceholder": "Select treatment type…",
|
"treatmentTypePlaceholder": "Select treatment type…",
|
||||||
"treatmentTypeNotSelected": "Type not selected",
|
"treatmentTypeNotSelected": "Type not selected",
|
||||||
@@ -808,7 +809,7 @@
|
|||||||
"entryStepNext": "Next",
|
"entryStepNext": "Next",
|
||||||
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
|
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
|
||||||
"toothChartTitleCompact": "Tooth chart",
|
"toothChartTitleCompact": "Tooth chart",
|
||||||
"toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.",
|
"toothChartHint": "Tap teeth to select or deselect. Shift-click another tooth in the same arch for a connected bridge. Click a bridge tooth to cut it out (still selected as a single); ends keep one bridge, middle splits into two.",
|
||||||
"toothConnectedHint": "Connected selection",
|
"toothConnectedHint": "Connected selection",
|
||||||
"toothConnectedSuffix": " (connected)",
|
"toothConnectedSuffix": " (connected)",
|
||||||
"connectedBadge": "Connected",
|
"connectedBadge": "Connected",
|
||||||
|
|||||||
@@ -574,6 +574,7 @@
|
|||||||
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
||||||
"loadingSchedule": "در حال بارگذاری برنامه...",
|
"loadingSchedule": "در حال بارگذاری برنامه...",
|
||||||
"infoPastViewOnly": "نوبتهای گذشته فقط قابل مشاهده هستند.",
|
"infoPastViewOnly": "نوبتهای گذشته فقط قابل مشاهده هستند.",
|
||||||
|
"infoEditBlockedHasTreatment": "این نوبت قابل ویرایش نیست چون یک درمان به آن متصل است.",
|
||||||
"infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.",
|
"infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.",
|
||||||
"errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائهدهنده است و قابل ویرایش نیست.",
|
"errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائهدهنده است و قابل ویرایش نیست.",
|
||||||
"successUpdated": "نوبت بهروزرسانی شد.",
|
"successUpdated": "نوبت بهروزرسانی شد.",
|
||||||
@@ -669,8 +670,8 @@
|
|||||||
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
||||||
"addCase": "افزودن پرونده",
|
"addCase": "افزودن پرونده",
|
||||||
"caseLabel": "پرونده {n}",
|
"caseLabel": "پرونده {n}",
|
||||||
"comments": "نظرات",
|
"comments": "یادداشتها",
|
||||||
"commentsPlaceholder": "یادداشتهای بالینی این پرونده را بنویسید...",
|
"commentsPlaceholder": "یادداشتهای بالینی این جزئیات را بنویسید…",
|
||||||
"treatmentType": "نوع درمان",
|
"treatmentType": "نوع درمان",
|
||||||
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
|
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
|
||||||
"treatmentTypeNotSelected": "نوع انتخاب نشده",
|
"treatmentTypeNotSelected": "نوع انتخاب نشده",
|
||||||
@@ -802,7 +803,7 @@
|
|||||||
"confirmSend": "تأیید ارسال",
|
"confirmSend": "تأیید ارسال",
|
||||||
"toothChartTitle": "نمودار دندانها FDI",
|
"toothChartTitle": "نمودار دندانها FDI",
|
||||||
"toothChartTitleCompact": "نمودار دندان",
|
"toothChartTitleCompact": "نمودار دندان",
|
||||||
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد.",
|
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل میسازد. کلیک روی دندان داخل پل آن را جدا میکند (همچنان انتخابشده بهصورت تک)؛ انتها یک پل میماند، وسط به دو پل تقسیم میشود.",
|
||||||
"toothConnectedHint": "انتخاب متصل",
|
"toothConnectedHint": "انتخاب متصل",
|
||||||
"toothConnectedSuffix": " (متصل)",
|
"toothConnectedSuffix": " (متصل)",
|
||||||
"connectedBadge": "متصل",
|
"connectedBadge": "متصل",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
||||||
"loadingSchedule": "Rooster laden...",
|
"loadingSchedule": "Rooster laden...",
|
||||||
"infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.",
|
"infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.",
|
||||||
|
"infoEditBlockedHasTreatment": "Deze afspraak kan niet worden bewerkt omdat er een behandeling aan gekoppeld is.",
|
||||||
"infoSelectPatient": "Selecteer een patiënt voor het boeken.",
|
"infoSelectPatient": "Selecteer een patiënt voor het boeken.",
|
||||||
"errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.",
|
"errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.",
|
||||||
"successUpdated": "Afspraak bijgewerkt.",
|
"successUpdated": "Afspraak bijgewerkt.",
|
||||||
@@ -668,8 +669,8 @@
|
|||||||
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
||||||
"addCase": "Case toevoegen",
|
"addCase": "Case toevoegen",
|
||||||
"caseLabel": "Case {n}",
|
"caseLabel": "Case {n}",
|
||||||
"comments": "Opmerkingen",
|
"comments": "Notities",
|
||||||
"commentsPlaceholder": "Schrijf klinische notities voor deze case...",
|
"commentsPlaceholder": "Schrijf klinische notities voor dit detail…",
|
||||||
"treatmentType": "Behandeltype",
|
"treatmentType": "Behandeltype",
|
||||||
"treatmentTypePlaceholder": "Selecteer behandeltype…",
|
"treatmentTypePlaceholder": "Selecteer behandeltype…",
|
||||||
"treatmentTypeNotSelected": "Type niet geselecteerd",
|
"treatmentTypeNotSelected": "Type niet geselecteerd",
|
||||||
@@ -801,7 +802,7 @@
|
|||||||
"confirmSend": "Bevestig verzending",
|
"confirmSend": "Bevestig verzending",
|
||||||
"toothChartTitle": "FDI-tanddiagram",
|
"toothChartTitle": "FDI-tanddiagram",
|
||||||
"toothChartTitleCompact": "Tanddiagram",
|
"toothChartTitleCompact": "Tanddiagram",
|
||||||
"toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.",
|
"toothChartHint": "Tik op tanden om te selecteren of deselecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden brug. Klik op een brugtand om die eruit te snijden (blijft geselecteerd als single); uiteinden houden één brug, midden splitst in twee.",
|
||||||
"toothConnectedHint": "Verbonden selectie",
|
"toothConnectedHint": "Verbonden selectie",
|
||||||
"toothConnectedSuffix": " (verbonden)",
|
"toothConnectedSuffix": " (verbonden)",
|
||||||
"connectedBadge": "Verbonden",
|
"connectedBadge": "Verbonden",
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do not mount clinic-only pages for lab orgs (avoids PERMISSION_CLINIC_ONLY toasts during redirect).
|
||||||
|
if (!canAccessDashboardRoute(currentOrganization, pathname)) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center app-web-bg">
|
||||||
|
{t('loadingWorkspace')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function AcceptInviteContent() {
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
passwordToggleLabels={passwordToggleLabels}
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
/>
|
/>
|
||||||
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
|
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
|
||||||
{t('activateAccount')}
|
{t('activateAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ function AcceptOrganizationInviteContent() {
|
|||||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||||
passwordToggleLabels={passwordToggleLabels}
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="primary" onClick={() => void handleNext()} fullWidth>
|
<Button type="button" variant="primary" onClick={() => handleNext()} fullWidth>
|
||||||
{tCommon('continue')}
|
{tCommon('continue')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function ForgotPasswordPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
isLoading={isSending}
|
isLoading={isSending}
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => void onSendCode()}
|
onClick={() => onSendCode()}
|
||||||
>
|
>
|
||||||
{t('sendCode')}
|
{t('sendCode')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export default function HomePage() {
|
|||||||
const t = useTranslations('landing');
|
const t = useTranslations('landing');
|
||||||
const tAuth = useTranslations('auth');
|
const tAuth = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { user } = useAuth();
|
const { user, isAuthReady } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] app-web-bg text-text-primary">
|
<div className="min-h-[100dvh] flex flex-col app-web-bg text-text-primary">
|
||||||
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
|
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
|
||||||
<div className="container mx-auto px-4 py-3 sm:py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
<div className="container mx-auto px-4 py-3 sm:py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||||
<Link href="/" className="text-xl sm:text-2xl font-semibold text-text-primary shrink-0">
|
<Link href="/" className="text-xl sm:text-2xl font-semibold text-text-primary shrink-0">
|
||||||
@@ -23,7 +23,9 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-3 ml-auto shrink-0">
|
<div className="flex items-center gap-1.5 sm:gap-3 ml-auto shrink-0">
|
||||||
<TopBarControls />
|
<TopBarControls />
|
||||||
{user ? (
|
{!isAuthReady ? (
|
||||||
|
<span className="inline-block h-9 w-24 sm:w-28 rounded-[var(--radius-md)] bg-background-card/60 animate-pulse" aria-hidden />
|
||||||
|
) : user ? (
|
||||||
<Link href="/today">
|
<Link href="/today">
|
||||||
<Button variant="primary" size="sm" className="sm:px-4 sm:py-2 sm:text-sm">
|
<Button variant="primary" size="sm" className="sm:px-4 sm:py-2 sm:text-sm">
|
||||||
{tAuth('dashboard')}
|
{tAuth('dashboard')}
|
||||||
@@ -48,7 +50,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
|
||||||
{t('heroTitle')}
|
{t('heroTitle')}
|
||||||
@@ -59,7 +61,7 @@ export default function HomePage() {
|
|||||||
{t('heroSubtitle')}
|
{t('heroSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!user && (
|
{isAuthReady && !user && (
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||||
<Link href="/register" className="w-full sm:w-auto">
|
<Link href="/register" className="w-full sm:w-auto">
|
||||||
<Button size="lg" variant="primary" fullWidth className="sm:w-auto sm:px-8">
|
<Button size="lg" variant="primary" fullWidth className="sm:w-auto sm:px-8">
|
||||||
@@ -109,7 +111,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-border/70 bg-background-secondary/80">
|
<footer className="mt-auto border-t border-border/70 bg-background-secondary/80">
|
||||||
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
|
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
|
||||||
<div>{t('footerCopyright')}</div>
|
<div>{t('footerCopyright')}</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,28 @@ export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId
|
|||||||
return [...set].sort() as FdiToothId[];
|
return [...set].sort() as FdiToothId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Never allow a 1-tooth connected group (no lone connected dots). */
|
||||||
|
function normalizeGroupKind(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionKind {
|
||||||
|
if (kind === 'connected' && teeth.length < 2) return 'single';
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroup(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionGroup | null {
|
||||||
|
if (teeth.length === 0) return null;
|
||||||
|
return {
|
||||||
|
groupId: newGroupId(),
|
||||||
|
kind: normalizeGroupKind(kind, teeth),
|
||||||
|
teeth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInArchOrder(teeth: FdiToothId[]): FdiToothId[] {
|
||||||
|
if (teeth.length === 0) return [];
|
||||||
|
const arch = archOrder(teeth[0]);
|
||||||
|
if (!arch) return [...teeth];
|
||||||
|
return [...teeth].sort((a, b) => arch.indexOf(a) - arch.indexOf(b));
|
||||||
|
}
|
||||||
|
|
||||||
/** Legacy / empty groups: each selected tooth becomes its own single group. */
|
/** Legacy / empty groups: each selected tooth becomes its own single group. */
|
||||||
export function groupsFromFlatTeeth(
|
export function groupsFromFlatTeeth(
|
||||||
teeth: readonly FdiToothId[],
|
teeth: readonly FdiToothId[],
|
||||||
@@ -36,11 +58,10 @@ export function groupsFromFlatTeeth(
|
|||||||
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
|
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
|
||||||
}))
|
}))
|
||||||
.filter((g) => g.teeth.length > 0)
|
.filter((g) => g.teeth.length > 0)
|
||||||
.map((g) =>
|
.map((g) => ({
|
||||||
g.kind === 'connected' && g.teeth.length < 2
|
...g,
|
||||||
? { ...g, kind: 'single' as const }
|
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||||
: g,
|
}));
|
||||||
);
|
|
||||||
const covered = new Set(next.flatMap((g) => g.teeth));
|
const covered = new Set(next.flatMap((g) => g.teeth));
|
||||||
for (const tooth of teeth) {
|
for (const tooth of teeth) {
|
||||||
if (!covered.has(tooth)) {
|
if (!covered.has(tooth)) {
|
||||||
@@ -94,8 +115,11 @@ export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[
|
|||||||
/**
|
/**
|
||||||
* Plain click:
|
* Plain click:
|
||||||
* - unselected → add as single
|
* - unselected → add as single
|
||||||
* - selected single → remove
|
* - selected single → remove (deselect)
|
||||||
* - selected in connected → drop the whole connected span and keep only this tooth as a single
|
* - in connected bridge → cut from bridge, keep selected as single:
|
||||||
|
* - end tooth → remainder stays one bridge (or demotes if 1 left)
|
||||||
|
* - middle tooth → left and right become separate bridges/singles
|
||||||
|
* Never leaves a 1-tooth connected group (no lone dots).
|
||||||
*/
|
*/
|
||||||
export function toggleToothInGroups(
|
export function toggleToothInGroups(
|
||||||
groups: ToothSelectionGroup[],
|
groups: ToothSelectionGroup[],
|
||||||
@@ -110,15 +134,26 @@ export function toggleToothInGroups(
|
|||||||
return groups.filter((g) => g.groupId !== owning.groupId);
|
return groups.filter((g) => g.groupId !== owning.groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse connected span: only the clicked tooth remains, as a separate single.
|
const ordered = sortInArchOrder(owning.teeth);
|
||||||
|
const idx = ordered.indexOf(tooth);
|
||||||
|
if (idx < 0) return groups;
|
||||||
|
|
||||||
|
const left = ordered.slice(0, idx);
|
||||||
|
const right = ordered.slice(idx + 1);
|
||||||
const next = groups.filter((g) => g.groupId !== owning.groupId);
|
const next = groups.filter((g) => g.groupId !== owning.groupId);
|
||||||
|
|
||||||
|
const leftGroup = makeGroup(left.length >= 2 ? 'connected' : 'single', left);
|
||||||
|
const rightGroup = makeGroup(right.length >= 2 ? 'connected' : 'single', right);
|
||||||
|
if (leftGroup) next.push(leftGroup);
|
||||||
|
if (rightGroup) next.push(rightGroup);
|
||||||
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shift-click creates a connected span between anchor and target on the same arch.
|
* Shift-click creates a connected span between anchor and target on the same arch.
|
||||||
* Any existing groups that intersect the span are replaced by the new connected group.
|
* Overlapping existing bridges are merged (union) into one connected bridge.
|
||||||
|
* Non-overlapping bridges are left alone. Singles inside the union are absorbed.
|
||||||
*/
|
*/
|
||||||
export function applyShiftRange(
|
export function applyShiftRange(
|
||||||
groups: ToothSelectionGroup[],
|
groups: ToothSelectionGroup[],
|
||||||
@@ -129,26 +164,47 @@ export function applyShiftRange(
|
|||||||
const span = teethBetweenInclusive(anchor, target);
|
const span = teethBetweenInclusive(anchor, target);
|
||||||
if (!span || span.length < 2) return null;
|
if (!span || span.length < 2) return null;
|
||||||
|
|
||||||
const spanSet = new Set(span);
|
const union = new Set<FdiToothId>(span);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.kind !== 'connected') continue;
|
||||||
|
if (!group.teeth.some((t) => union.has(t))) continue;
|
||||||
|
for (const t of group.teeth) {
|
||||||
|
if (!union.has(t)) {
|
||||||
|
union.add(t);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unionTeeth = sortInArchOrder([...union] as FdiToothId[]);
|
||||||
|
if (unionTeeth.length < 2) return null;
|
||||||
|
|
||||||
const next = groups
|
const next = groups
|
||||||
.map((g) => ({
|
.map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
|
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
|
||||||
}))
|
}))
|
||||||
.filter((g) => g.teeth.length > 0)
|
.filter((g) => g.teeth.length > 0)
|
||||||
.map((g) =>
|
.map((g) => ({
|
||||||
g.kind === 'connected' && g.teeth.length < 2
|
...g,
|
||||||
? { ...g, kind: 'single' as const }
|
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||||
: g,
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
next.push({ groupId: newGroupId(), kind: 'connected', teeth: span });
|
next.push({
|
||||||
|
groupId: newGroupId(),
|
||||||
|
kind: 'connected',
|
||||||
|
teeth: unionTeeth,
|
||||||
|
});
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
|
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
|
||||||
* selectionGroupId so connected→single collapses do not leave stale group ids.
|
* selectionGroupId so group changes do not leave stale group ids.
|
||||||
*/
|
*/
|
||||||
export function pruneToothProsthesisForGroups<
|
export function pruneToothProsthesisForGroups<
|
||||||
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
|
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
|
||||||
@@ -181,7 +237,7 @@ export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGro
|
|||||||
if (teeth.length === 0) continue;
|
if (teeth.length === 0) continue;
|
||||||
out.push({
|
out.push({
|
||||||
groupId,
|
groupId,
|
||||||
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
|
kind: normalizeGroupKind(kind, teeth),
|
||||||
teeth,
|
teeth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,13 +203,16 @@ export function AppointmentBookingModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
|
if (
|
||||||
|
!editingAppointment &&
|
||||||
|
isSameLocalCalendarDay(scheduleDate, now) &&
|
||||||
|
startAt.getTime() < now.getTime()
|
||||||
|
) {
|
||||||
setError(t('errorPastSchedule'));
|
setError(t('errorPastSchedule'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
if (!editingAppointment && compareLocalDayStart(scheduleDate, now) < 0) {
|
||||||
if (compareLocalDayStart(scheduleDate, today) < 0) {
|
|
||||||
setError(t('errorPastViewOnly'));
|
setError(t('errorPastViewOnly'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -319,7 +322,7 @@ export function AppointmentBookingModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void onDelete()}
|
onClick={() => onDelete()}
|
||||||
disabled={loading || deleting}
|
disabled={loading || deleting}
|
||||||
isLoading={deleting}
|
isLoading={deleting}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -342,7 +345,7 @@ export function AppointmentBookingModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void handleSubmit()}
|
onClick={() => handleSubmit()}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ interface AppointmentScheduleGridProps {
|
|||||||
appointments: AppointmentRecord[];
|
appointments: AppointmentRecord[];
|
||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
canBook: boolean;
|
canBook: boolean;
|
||||||
|
/** Same message used for title + click toast when booking is blocked. */
|
||||||
|
slotBlockReason?: string | null;
|
||||||
|
/** Same message used for title + click toast when editing an appointment is blocked. */
|
||||||
|
appointmentEditBlockReason?: (appointment: AppointmentRecord) => string | null;
|
||||||
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
|
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
|
||||||
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||||
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
||||||
@@ -92,6 +96,8 @@ export function AppointmentScheduleGrid({
|
|||||||
appointments,
|
appointments,
|
||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
canBook,
|
canBook,
|
||||||
|
slotBlockReason = null,
|
||||||
|
appointmentEditBlockReason,
|
||||||
onSlotClick,
|
onSlotClick,
|
||||||
onAppointmentClick,
|
onAppointmentClick,
|
||||||
onAppointmentOutsideHours,
|
onAppointmentOutsideHours,
|
||||||
@@ -161,9 +167,6 @@ export function AppointmentScheduleGrid({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canBook) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onAppointmentClick?.(apt);
|
onAppointmentClick?.(apt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +219,7 @@ export function AppointmentScheduleGrid({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={hour}
|
key={hour}
|
||||||
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
|
className="absolute inset-x-0 text-[11px] text-text-muted flex items-center justify-center px-0.5 border-b border-border/50"
|
||||||
style={{ top, height }}
|
style={{ top, height }}
|
||||||
>
|
>
|
||||||
{formatMinuteLabel(hour * 60, locale)}
|
{formatMinuteLabel(hour * 60, locale)}
|
||||||
@@ -247,13 +250,15 @@ export function AppointmentScheduleGrid({
|
|||||||
SCHEDULE_SLOT_MINUTES,
|
SCHEDULE_SLOT_MINUTES,
|
||||||
p.dayBlocks,
|
p.dayBlocks,
|
||||||
);
|
);
|
||||||
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
|
const structuralDisabled = columnFullyDisabled || !slotActive;
|
||||||
|
const bookingBlocked = Boolean(slotBlockReason) || !canBook;
|
||||||
|
const slotLooksDisabled = structuralDisabled || bookingBlocked;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${p.userId}-${slotStartMinute}`}
|
key={`${p.userId}-${slotStartMinute}`}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={slotDisabled}
|
disabled={structuralDisabled}
|
||||||
title={
|
title={
|
||||||
columnFullyDisabled
|
columnFullyDisabled
|
||||||
? p.hasWorkingHours
|
? p.hasWorkingHours
|
||||||
@@ -261,14 +266,16 @@ export function AppointmentScheduleGrid({
|
|||||||
: t('slotHoursNotConfigured')
|
: t('slotHoursNotConfigured')
|
||||||
: !slotActive
|
: !slotActive
|
||||||
? t('slotOutsideHours')
|
? t('slotOutsideHours')
|
||||||
: slotDisabled
|
: slotBlockReason
|
||||||
? t('slotCannotCreate')
|
? slotBlockReason
|
||||||
: t('slotBookAt', {
|
: bookingBlocked
|
||||||
time: formatMinuteLabel(slotStartMinute, locale),
|
? t('slotCannotCreate')
|
||||||
})
|
: t('slotBookAt', {
|
||||||
|
time: formatMinuteLabel(slotStartMinute, locale),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||||
slotDisabled
|
slotLooksDisabled
|
||||||
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
||||||
: 'hover:bg-primary/8 cursor-pointer'
|
: 'hover:bg-primary/8 cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
@@ -302,8 +309,10 @@ export function AppointmentScheduleGrid({
|
|||||||
p.dayBlocks,
|
p.dayBlocks,
|
||||||
);
|
);
|
||||||
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
|
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
|
||||||
|
const editBlockReason = appointmentEditBlockReason?.(apt) ?? null;
|
||||||
const bannerTitle = [
|
const bannerTitle = [
|
||||||
patientName,
|
patientName,
|
||||||
|
editBlockReason,
|
||||||
outsideHours ? t('outsideHoursBlocked') : null,
|
outsideHours ? t('outsideHoursBlocked') : null,
|
||||||
clusterSize > 1
|
clusterSize > 1
|
||||||
? t('overlappingChoose', { count: clusterSize })
|
? t('overlappingChoose', { count: clusterSize })
|
||||||
@@ -328,7 +337,11 @@ export function AppointmentScheduleGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
|
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
|
||||||
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
outsideHours || editBlockReason
|
||||||
|
? 'opacity-70'
|
||||||
|
: ''
|
||||||
|
} ${
|
||||||
|
outsideHours ? 'ring-1 ring-amber-500/60' : ''
|
||||||
} ${
|
} ${
|
||||||
isUnderOneHour
|
isUnderOneHour
|
||||||
? 'items-center justify-center px-0.5 py-0'
|
? 'items-center justify-center px-0.5 py-0'
|
||||||
@@ -377,10 +390,6 @@ export function AppointmentScheduleGrid({
|
|||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
anchorRect={overlapPopover.anchorRect}
|
anchorRect={overlapPopover.anchorRect}
|
||||||
onSelect={(apt) => {
|
onSelect={(apt) => {
|
||||||
if (!canBook) {
|
|
||||||
setOverlapPopover(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
||||||
if (
|
if (
|
||||||
provider &&
|
provider &&
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
|
|||||||
export function AppointmentsPage() {
|
export function AppointmentsPage() {
|
||||||
const t = useTranslations('appointments');
|
const t = useTranslations('appointments');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
||||||
@@ -117,8 +118,19 @@ export function AppointmentsPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function appointmentEditBlockReason(appointment: AppointmentRecord): string | null {
|
||||||
|
if (!canManageAppointments) {
|
||||||
|
return tCommon('readOnlyAccess');
|
||||||
|
}
|
||||||
|
if (isViewingPastDay && appointment.hasTreatment) {
|
||||||
|
return t('infoEditBlockedHasTreatment');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
||||||
if (!canManageAppointments) {
|
if (!canManageAppointments) {
|
||||||
|
toast.showInfo(tCommon('readOnlyAccess'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isViewingPastDay) {
|
if (isViewingPastDay) {
|
||||||
@@ -133,11 +145,9 @@ export function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||||
if (!canManageAppointments) {
|
const blockReason = appointmentEditBlockReason(appointment);
|
||||||
return;
|
if (blockReason) {
|
||||||
}
|
toast.showInfo(blockReason);
|
||||||
if (isViewingPastDay) {
|
|
||||||
toast.showInfo(t('infoPastViewOnly'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
||||||
@@ -254,6 +264,14 @@ export function AppointmentsPage() {
|
|||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
canBook={canManageAppointments && !isViewingPastDay}
|
canBook={canManageAppointments && !isViewingPastDay}
|
||||||
|
appointmentEditBlockReason={appointmentEditBlockReason}
|
||||||
|
slotBlockReason={
|
||||||
|
!canManageAppointments
|
||||||
|
? tCommon('readOnlyAccess')
|
||||||
|
: isViewingPastDay
|
||||||
|
? t('infoPastViewOnly')
|
||||||
|
: null
|
||||||
|
}
|
||||||
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
|
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
|
||||||
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
||||||
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
|
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
|
||||||
@@ -281,11 +299,10 @@ export function AppointmentsPage() {
|
|||||||
loading={savingAppointment}
|
loading={savingAppointment}
|
||||||
canDelete={
|
canDelete={
|
||||||
canManageAppointments &&
|
canManageAppointments &&
|
||||||
!isViewingPastDay &&
|
|
||||||
!!activeEditingAppointment &&
|
!!activeEditingAppointment &&
|
||||||
!activeEditingAppointment.hasTreatment
|
!activeEditingAppointment.hasTreatment
|
||||||
}
|
}
|
||||||
onDelete={() => void handleDeleteEditingAppointment()}
|
onDelete={() => handleDeleteEditingAppointment()}
|
||||||
deleting={deletingAppointment}
|
deleting={deletingAppointment}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { useAsyncAction } from '@/lib/hooks/useAsyncAction';
|
||||||
import { authApi } from '@/lib/api/auth';
|
import { authApi } from '@/lib/api/auth';
|
||||||
import type { SubscriptionAlertData } from '@/types/subscription';
|
import type { SubscriptionAlertData } from '@/types/subscription';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
|
||||||
function warningTooltip(
|
function warningTooltip(
|
||||||
data: SubscriptionAlertData | null,
|
data: SubscriptionAlertData | null,
|
||||||
@@ -32,6 +34,7 @@ export function DashboardAccountMenu() {
|
|||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tAccount = useTranslations('accountMenu');
|
const tAccount = useTranslations('accountMenu');
|
||||||
const { user, currentOrganization, logout } = useAuth();
|
const { user, currentOrganization, logout } = useAuth();
|
||||||
|
const { pending: logoutPending, run: runLogout } = useAsyncAction();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
|
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
|
||||||
@@ -70,9 +73,11 @@ export function DashboardAccountMenu() {
|
|||||||
const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]);
|
const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]);
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
setOpen(false);
|
return runLogout(async () => {
|
||||||
void logout();
|
await logout();
|
||||||
}, [logout]);
|
setOpen(false);
|
||||||
|
});
|
||||||
|
}, [logout, runLogout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-[120]" ref={menuRef}>
|
<div className="relative z-[120]" ref={menuRef}>
|
||||||
@@ -146,15 +151,17 @@ export function DashboardAccountMenu() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-1">
|
<div className="border-t border-border/60 pt-1">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-secondary hover:bg-background-card/70 hover:text-text-primary"
|
className="flex w-full items-center justify-start gap-3 rounded-none px-3 py-2.5 text-sm text-text-secondary hover:bg-background-card/70 hover:text-text-primary"
|
||||||
onClick={handleLogout}
|
isLoading={logoutPending}
|
||||||
|
onClick={() => handleLogout()}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 icon-flat shrink-0" />
|
<LogOut className="h-4 w-4 icon-flat shrink-0" />
|
||||||
{t('signOut')}
|
{t('signOut')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) {
|
|||||||
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
|
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={session.labCaseId}
|
caseId={session.labCaseId}
|
||||||
|
viewerSide={session.accessMode === 'lab' ? 'LAB' : 'CLINIC'}
|
||||||
canPost={canPostComments}
|
canPost={canPostComments}
|
||||||
canToggleVisibility={canToggleCommentVisibility}
|
canToggleVisibility={canToggleCommentVisibility}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import type {
|
|||||||
PaginatedLabCases,
|
PaginatedLabCases,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
export function CasesPage() {
|
export function CasesPage() {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
@@ -308,9 +308,9 @@ export function CasesPage() {
|
|||||||
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr] lg:items-stretch">
|
||||||
<section
|
<section
|
||||||
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
|
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 h-full lg:min-h-[420px] ${
|
||||||
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
|
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -394,13 +394,13 @@ export function CasesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
{loadingList ? (
|
{loadingList ? (
|
||||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
) : cases.length === 0 ? (
|
) : cases.length === 0 ? (
|
||||||
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
|
<ul className="space-y-2 pr-1">
|
||||||
{cases.map((item) => {
|
{cases.map((item) => {
|
||||||
const isActive = item.id === selectedCaseId;
|
const isActive = item.id === selectedCaseId;
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ export function CasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination.totalPages > 1 ? (
|
{pagination.totalPages > 1 ? (
|
||||||
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
|
<div className="flex shrink-0 items-center justify-between gap-2 pt-2 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -535,6 +535,7 @@ export function CasesPage() {
|
|||||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={selectedCaseId}
|
caseId={selectedCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost={canEditComments}
|
canPost={canEditComments}
|
||||||
canToggleVisibility={canEditComments}
|
canToggleVisibility={canEditComments}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function LabCaseAttachmentsDialog({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={downloadingAll}
|
disabled={downloadingAll}
|
||||||
onClick={() => void handleDownloadAll()}
|
onClick={() => handleDownloadAll()}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 me-1.5" />
|
<Download className="h-4 w-4 me-1.5" />
|
||||||
{t('downloadAllAttachments')}
|
{t('downloadAllAttachments')}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff, Send } from 'lucide-react';
|
import { Eye, EyeOff, Send } from 'lucide-react';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
|
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
|
||||||
import type { LabCaseComment } from '@/types/cases';
|
import type { LabCaseComment } from '@/types/cases';
|
||||||
|
|
||||||
|
export type LabCaseCommentViewerSide = 'LAB' | 'CLINIC';
|
||||||
|
|
||||||
interface LabCaseCommentsPanelProps {
|
interface LabCaseCommentsPanelProps {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
|
viewerSide: LabCaseCommentViewerSide;
|
||||||
canPost: boolean;
|
canPost: boolean;
|
||||||
canToggleVisibility: boolean;
|
canToggleVisibility: boolean;
|
||||||
loadComments: () => Promise<LabCaseComment[]>;
|
loadComments: () => Promise<LabCaseComment[]>;
|
||||||
@@ -23,8 +27,15 @@ interface LabCaseCommentsPanelProps {
|
|||||||
onComposerValueChange?: (value: string) => void;
|
onComposerValueChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortNewestFirst(items: LabCaseComment[]): LabCaseComment[] {
|
||||||
|
return [...items].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LabCaseCommentsPanel({
|
export function LabCaseCommentsPanel({
|
||||||
caseId,
|
caseId,
|
||||||
|
viewerSide,
|
||||||
canPost,
|
canPost,
|
||||||
canToggleVisibility,
|
canToggleVisibility,
|
||||||
loadComments,
|
loadComments,
|
||||||
@@ -42,18 +53,21 @@ export function LabCaseCommentsPanel({
|
|||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
||||||
|
const toggleBusy = useAsyncActionById();
|
||||||
|
|
||||||
|
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await loadComments();
|
const items = await loadComments();
|
||||||
setComments(items);
|
setComments(sortNewestFirst(items));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loadComments, onError, t]);
|
}, [loadComments, onError, t, tErrors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -65,7 +79,7 @@ export function LabCaseCommentsPanel({
|
|||||||
setPosting(true);
|
setPosting(true);
|
||||||
try {
|
try {
|
||||||
const created = await onPost(trimmed, visibleToClinic);
|
const created = await onPost(trimmed, visibleToClinic);
|
||||||
setComments((prev) => [...prev, created]);
|
setComments((prev) => sortNewestFirst([created, ...prev]));
|
||||||
setBody('');
|
setBody('');
|
||||||
setVisibleToClinic(false);
|
setVisibleToClinic(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -84,16 +98,21 @@ export function LabCaseCommentsPanel({
|
|||||||
|
|
||||||
async function handleToggle(comment: LabCaseComment) {
|
async function handleToggle(comment: LabCaseComment) {
|
||||||
if (!onToggleVisibility || !canToggleVisibility) return;
|
if (!onToggleVisibility || !canToggleVisibility) return;
|
||||||
try {
|
await toggleBusy.run(comment.id, async () => {
|
||||||
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
try {
|
||||||
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
||||||
} catch (error: unknown) {
|
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||||
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
|
} catch (error: unknown) {
|
||||||
}
|
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const composerInputClass =
|
||||||
|
'min-w-0 flex-1 h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
|
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -101,15 +120,22 @@ export function LabCaseCommentsPanel({
|
|||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted">{t('empty')}</p>
|
<p className="text-xs text-text-muted">{t('empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto overflow-x-hidden rounded-md border border-border bg-background p-2 space-y-2">
|
||||||
{comments.map((comment) => (
|
{orderedComments.map((comment) => {
|
||||||
<li
|
const mine = comment.authorSide === viewerSide;
|
||||||
key={comment.id}
|
return (
|
||||||
className="rounded-md border border-border bg-background p-2 text-sm"
|
<div
|
||||||
>
|
key={comment.id}
|
||||||
<div className="flex items-start justify-between gap-2">
|
className={`flex min-w-0 ${mine ? 'justify-start' : 'justify-end'}`}
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted">
|
<div
|
||||||
|
className={`max-w-[85%] min-w-0 text-sm break-words ${mine ? 'text-start' : 'text-end'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted ${
|
||||||
|
mine ? 'justify-start' : 'justify-end'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span className="font-medium text-text-secondary">
|
<span className="font-medium text-text-secondary">
|
||||||
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
||||||
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
||||||
@@ -121,28 +147,32 @@ export function LabCaseCommentsPanel({
|
|||||||
<span>{t('hiddenFromClinic')}</span>
|
<span>{t('hiddenFromClinic')}</span>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(comment)}
|
||||||
|
disabled={toggleBusy.pendingId !== null}
|
||||||
|
className={`shrink-0 p-0.5 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||||
|
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
|
||||||
|
}`}
|
||||||
|
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
|
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
|
aria-busy={toggleBusy.pendingId === comment.id || undefined}
|
||||||
|
>
|
||||||
|
{comment.visibleToClinic ? (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
<p className="mt-0.5 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
||||||
</div>
|
</div>
|
||||||
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleToggle(comment)}
|
|
||||||
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
|
|
||||||
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
|
||||||
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
|
||||||
>
|
|
||||||
{comment.visibleToClinic ? (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
);
|
||||||
))}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canPost && deferSubmit ? (
|
{canPost && deferSubmit ? (
|
||||||
@@ -151,46 +181,48 @@ export function LabCaseCommentsPanel({
|
|||||||
value={composerValue ?? ''}
|
value={composerValue ?? ''}
|
||||||
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
rows={2}
|
rows={1}
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
className="w-full h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : canPost ? (
|
) : canPost ? (
|
||||||
<div className="flex items-end gap-2 border-t border-border pt-2">
|
<div className="flex items-center gap-2 border-t border-border pt-2 min-w-0">
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
onKeyDown={handleComposerKeyDown}
|
onKeyDown={handleComposerKeyDown}
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
rows={2}
|
rows={1}
|
||||||
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
className={composerInputClass}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 pb-1">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{canToggleVisibility ? (
|
{canToggleVisibility ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVisibleToClinic((v) => !v)}
|
onClick={() => setVisibleToClinic((v) => !v)}
|
||||||
className={`shrink-0 p-2 rounded-md border transition-colors ${
|
className={`shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 ${
|
||||||
visibleToClinic
|
visibleToClinic ? '' : 'opacity-50'
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-text-muted hover:text-text-primary'
|
|
||||||
}`}
|
}`}
|
||||||
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||||
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||||
aria-pressed={visibleToClinic}
|
aria-pressed={visibleToClinic}
|
||||||
>
|
>
|
||||||
{visibleToClinic ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
{visibleToClinic ? (
|
||||||
|
<Eye className="lucide-on-primary h-4 w-4" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="lucide-on-primary h-4 w-4" color="#fff" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePost()}
|
onClick={() => handlePost()}
|
||||||
disabled={posting || !body.trim()}
|
disabled={posting || !body.trim()}
|
||||||
className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
title={t('send')}
|
title={t('send')}
|
||||||
aria-label={t('send')}
|
aria-label={t('send')}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="lucide-on-primary h-4 w-4 rtl:-scale-x-100" color="#fff" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function LabCaseShareQrDialog({ open, onClose, shareUrl }: LabCaseShareQr
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={copying}
|
disabled={copying}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={() => void handleCopy()}
|
onClick={() => handleCopy()}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4 me-1.5" />
|
<Copy className="h-4 w-4 me-1.5" />
|
||||||
{t('copyShareLink')}
|
{t('copyShareLink')}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface TaskRowProps {
|
|||||||
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
||||||
onToggleComments: (taskId: string) => void;
|
onToggleComments: (taskId: string) => void;
|
||||||
onCommentError: (message: string) => void;
|
onCommentError: (message: string) => void;
|
||||||
onShowInCase?: (task: LabTaskListItem) => void;
|
onShowInCase?: (task: LabTaskListItem) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
@@ -217,6 +217,7 @@ export function TaskRow({
|
|||||||
<div className="px-3 pb-3 border-t border-border/50">
|
<div className="px-3 pb-3 border-t border-border/50">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={task.labCaseId}
|
caseId={task.labCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export function TasksPage() {
|
|||||||
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||||
}
|
}
|
||||||
onCommentError={showError}
|
onCommentError={showError}
|
||||||
onShowInCase={flatMode ? (row) => void handleShowInCase(row) : undefined}
|
onShowInCase={flatMode ? (row) => handleShowInCase(row) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -538,6 +538,7 @@ export function TasksPage() {
|
|||||||
<div className="border-b border-border/50 px-3 pb-3">
|
<div className="border-b border-border/50 px-3 pb-3">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={caseGroup.labCaseId}
|
caseId={caseGroup.labCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function OrganizationConnectionsMobileList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
||||||
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
|
disabled={pendingConnectionRowId !== null}
|
||||||
onClick={() => onRespond(row.id, 'ACCEPT')}
|
onClick={() => onRespond(row.id, 'ACCEPT')}
|
||||||
aria-label={labels.acceptRequest}
|
aria-label={labels.acceptRequest}
|
||||||
title={labels.acceptRequest}
|
title={labels.acceptRequest}
|
||||||
@@ -145,7 +145,7 @@ export function OrganizationConnectionsMobileList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
|
||||||
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
|
disabled={pendingConnectionRowId !== null}
|
||||||
onClick={() => onRespond(row.id, 'REJECT')}
|
onClick={() => onRespond(row.id, 'REJECT')}
|
||||||
aria-label={labels.declineRequest}
|
aria-label={labels.declineRequest}
|
||||||
title={labels.declineRequest}
|
title={labels.declineRequest}
|
||||||
@@ -158,7 +158,7 @@ export function OrganizationConnectionsMobileList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
|
||||||
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
|
disabled={deleteConnectionRowId !== null}
|
||||||
onClick={() => onDeleteConnection(row.id)}
|
onClick={() => onDeleteConnection(row.id)}
|
||||||
aria-label={labels.removeConnection}
|
aria-label={labels.removeConnection}
|
||||||
title={labels.removeConnection}
|
title={labels.removeConnection}
|
||||||
@@ -195,7 +195,7 @@ export function OrganizationConnectionsMobileList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
||||||
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== result.id}
|
disabled={pendingConnectionRowId !== null}
|
||||||
onClick={() => onSendConnectionRequest(result.id)}
|
onClick={() => onSendConnectionRequest(result.id)}
|
||||||
aria-label={labels.sendRequest}
|
aria-label={labels.sendRequest}
|
||||||
title={labels.sendRequest}
|
title={labels.sendRequest}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
|
||||||
import { Check, Trash2, UserPlus, X } from 'lucide-react';
|
import { Check, Trash2, UserPlus, X } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
|
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
|
||||||
@@ -81,8 +82,8 @@ export function OrganizationsPage() {
|
|||||||
const [mode, setMode] = useState<TableMode>('existing');
|
const [mode, setMode] = useState<TableMode>('existing');
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
|
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
|
||||||
const [pendingConnectionRowId, setPendingConnectionRowId] = useState<string | null>(null);
|
const connectionBusy = useAsyncActionById();
|
||||||
const [deleteConnectionRowId, setDeleteConnectionRowId] = useState<string | null>(null);
|
const deleteBusy = useAsyncActionById();
|
||||||
|
|
||||||
const [items, setItems] = useState<CounterpartItemDto[]>([]);
|
const [items, setItems] = useState<CounterpartItemDto[]>([]);
|
||||||
const [manualOrganizationName, setManualOrganizationName] = useState('');
|
const [manualOrganizationName, setManualOrganizationName] = useState('');
|
||||||
@@ -171,20 +172,19 @@ export function OrganizationsPage() {
|
|||||||
}, [query, formatApiMessage, showError, setToastError]);
|
}, [query, formatApiMessage, showError, setToastError]);
|
||||||
|
|
||||||
async function submitConnectionRequest(targetOrganizationId: string) {
|
async function submitConnectionRequest(targetOrganizationId: string) {
|
||||||
setPendingConnectionRowId(targetOrganizationId);
|
await connectionBusy.run(targetOrganizationId, async () => {
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await organizationApi.createConnectionRequest(targetOrganizationId);
|
await organizationApi.createConnectionRequest(targetOrganizationId);
|
||||||
toast.showSuccess(t('successConnectionSent', { counterpart }));
|
toast.showSuccess(t('successConnectionSent', { counterpart }));
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setMode('existing');
|
setMode('existing');
|
||||||
await loadList();
|
await loadList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiMessage(e));
|
toast.showError(formatApiMessage(e));
|
||||||
} finally {
|
}
|
||||||
setPendingConnectionRowId(null);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendInvite() {
|
async function sendInvite() {
|
||||||
@@ -272,34 +272,32 @@ export function OrganizationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
|
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
|
||||||
setPendingConnectionRowId(connectionId);
|
await connectionBusy.run(connectionId, async () => {
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await organizationApi.respondToConnectionRequest(connectionId, action);
|
await organizationApi.respondToConnectionRequest(connectionId, action);
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
|
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
|
||||||
);
|
);
|
||||||
notifyPendingConnectionsChanged();
|
notifyPendingConnectionsChanged();
|
||||||
await loadList();
|
await loadList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiMessage(e));
|
toast.showError(formatApiMessage(e));
|
||||||
} finally {
|
}
|
||||||
setPendingConnectionRowId(null);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteConnection(connectionId: string) {
|
async function deleteConnection(connectionId: string) {
|
||||||
setDeleteConnectionRowId(connectionId);
|
await deleteBusy.run(connectionId, async () => {
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await organizationApi.deleteConnection(connectionId);
|
await organizationApi.deleteConnection(connectionId);
|
||||||
toast.showSuccess(t('successRemoved'));
|
toast.showSuccess(t('successRemoved'));
|
||||||
await loadList();
|
await loadList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiMessage(e));
|
toast.showError(formatApiMessage(e));
|
||||||
} finally {
|
}
|
||||||
setDeleteConnectionRowId(null);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearchView() {
|
function clearSearchView() {
|
||||||
@@ -320,7 +318,7 @@ export function OrganizationsPage() {
|
|||||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{tabLabel}</h1>
|
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{tabLabel}</h1>
|
||||||
<p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p>
|
<p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" size="sm" className="w-full sm:w-auto shrink-0" onClick={() => void openInvitationHistory()}>
|
<Button type="button" size="sm" className="w-full sm:w-auto shrink-0" onClick={() => openInvitationHistory()}>
|
||||||
{t('invitationHistory')}
|
{t('invitationHistory')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,8 +347,8 @@ export function OrganizationsPage() {
|
|||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
currentOrganizationId={currentOrganization.id}
|
currentOrganizationId={currentOrganization.id}
|
||||||
counterpart={counterpart}
|
counterpart={counterpart}
|
||||||
pendingConnectionRowId={pendingConnectionRowId}
|
pendingConnectionRowId={connectionBusy.pendingId}
|
||||||
deleteConnectionRowId={deleteConnectionRowId}
|
deleteConnectionRowId={deleteBusy.pendingId}
|
||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
copyingInvitationId={copyingInvitationId}
|
copyingInvitationId={copyingInvitationId}
|
||||||
showInviteForm={showInviteForm}
|
showInviteForm={showInviteForm}
|
||||||
@@ -360,14 +358,14 @@ export function OrganizationsPage() {
|
|||||||
formatConnectionStatusLabel={formatConnectionStatusLabel}
|
formatConnectionStatusLabel={formatConnectionStatusLabel}
|
||||||
formatTableDate={formatTableDate}
|
formatTableDate={formatTableDate}
|
||||||
getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)}
|
getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)}
|
||||||
onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)}
|
onCopyInvitation={(row) => handleCopyInvitationFromRow(row)}
|
||||||
onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)}
|
onRespond={(rowId, action) => respondToPendingConnection(rowId, action)}
|
||||||
onDeleteConnection={(rowId) => void deleteConnection(rowId)}
|
onDeleteConnection={(rowId) => deleteConnection(rowId)}
|
||||||
onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)}
|
onSendConnectionRequest={(orgId) => submitConnectionRequest(orgId)}
|
||||||
onToggleInviteForm={() => setShowInviteForm((v) => !v)}
|
onToggleInviteForm={() => setShowInviteForm((v) => !v)}
|
||||||
onManualOrganizationNameChange={setManualOrganizationName}
|
onManualOrganizationNameChange={setManualOrganizationName}
|
||||||
onManualOwnerEmailChange={setManualOwnerEmail}
|
onManualOwnerEmailChange={setManualOwnerEmail}
|
||||||
onSendInvite={() => void sendInvite()}
|
onSendInvite={() => sendInvite()}
|
||||||
labels={{
|
labels={{
|
||||||
loading: tCommon('loadingEllipsis'),
|
loading: tCommon('loadingEllipsis'),
|
||||||
emptyConnections: t('emptyConnections'),
|
emptyConnections: t('emptyConnections'),
|
||||||
@@ -462,8 +460,8 @@ export function OrganizationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
|
||||||
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
|
disabled={connectionBusy.pendingId !== null}
|
||||||
onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')}
|
onClick={() => respondToPendingConnection(row.id, 'ACCEPT')}
|
||||||
aria-label={t('acceptRequest')}
|
aria-label={t('acceptRequest')}
|
||||||
title={t('acceptRequest')}
|
title={t('acceptRequest')}
|
||||||
>
|
>
|
||||||
@@ -472,8 +470,8 @@ export function OrganizationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
|
||||||
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
|
disabled={connectionBusy.pendingId !== null}
|
||||||
onClick={() => void respondToPendingConnection(row.id, 'REJECT')}
|
onClick={() => respondToPendingConnection(row.id, 'REJECT')}
|
||||||
aria-label={t('declineRequest')}
|
aria-label={t('declineRequest')}
|
||||||
title={t('declineRequest')}
|
title={t('declineRequest')}
|
||||||
>
|
>
|
||||||
@@ -486,8 +484,8 @@ export function OrganizationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
|
||||||
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
|
disabled={deleteBusy.pendingId !== null}
|
||||||
onClick={() => void deleteConnection(row.id)}
|
onClick={() => deleteConnection(row.id)}
|
||||||
aria-label={t('removeConnection')}
|
aria-label={t('removeConnection')}
|
||||||
title={t('removeConnection')}
|
title={t('removeConnection')}
|
||||||
>
|
>
|
||||||
@@ -514,10 +512,8 @@ export function OrganizationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
|
||||||
disabled={
|
disabled={connectionBusy.pendingId !== null}
|
||||||
pendingConnectionRowId !== null && pendingConnectionRowId !== r.id
|
onClick={() => submitConnectionRequest(r.id)}
|
||||||
}
|
|
||||||
onClick={() => void submitConnectionRequest(r.id)}
|
|
||||||
aria-label={t('sendRequest')}
|
aria-label={t('sendRequest')}
|
||||||
title={t('sendRequest')}
|
title={t('sendRequest')}
|
||||||
>
|
>
|
||||||
@@ -556,7 +552,7 @@ export function OrganizationsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
isLoading={inviteLoading}
|
isLoading={inviteLoading}
|
||||||
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
|
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
|
||||||
onClick={() => void sendInvite()}
|
onClick={() => sendInvite()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{t('sendInvitation')}
|
{t('sendInvitation')}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function PatientsPage() {
|
|||||||
isOpen={isCreateOpen}
|
isOpen={isCreateOpen}
|
||||||
formData={patientForm}
|
formData={patientForm}
|
||||||
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
|
||||||
onSubmit={() => void handleCreatePatient()}
|
onSubmit={() => handleCreatePatient()}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsCreateOpen(false);
|
setIsCreateOpen(false);
|
||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export function AccountSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isLoading={participationLoading}
|
isLoading={participationLoading}
|
||||||
onClick={() => void confirmRevokeParticipation()}
|
onClick={() => confirmRevokeParticipation()}
|
||||||
>
|
>
|
||||||
{t('participateConfirmRevokeConfirm')}
|
{t('participateConfirmRevokeConfirm')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function OwnerWorkingHoursDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
onClick={() => void handleSkip()}
|
onClick={() => handleSkip()}
|
||||||
>
|
>
|
||||||
{tStaff('skipForNow')}
|
{tStaff('skipForNow')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -148,7 +148,7 @@ export function OwnerWorkingHoursDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
disabled={Boolean(validationError)}
|
disabled={Boolean(validationError)}
|
||||||
onClick={() => void handleSave()}
|
onClick={() => handleSave()}
|
||||||
>
|
>
|
||||||
{t('participateSaveHours')}
|
{t('participateSaveHours')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CalendarDays } from 'lucide-react';
|
|||||||
import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime';
|
import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime';
|
||||||
import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
||||||
|
import { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
|
||||||
import {
|
import {
|
||||||
formatIsoAsGregorianDateInput,
|
formatIsoAsGregorianDateInput,
|
||||||
maskGregorianDateTyping,
|
maskGregorianDateTyping,
|
||||||
@@ -45,6 +46,11 @@ export function AppDateInput({
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
|
||||||
|
open: panelOpen && !disabled,
|
||||||
|
anchorRef: rootRef,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setText(
|
setText(
|
||||||
@@ -56,9 +62,11 @@ export function AppDateInput({
|
|||||||
if (!panelOpen) return;
|
if (!panelOpen) return;
|
||||||
|
|
||||||
function onPointerDown(event: MouseEvent) {
|
function onPointerDown(event: MouseEvent) {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) {
|
const target = event.target as Node;
|
||||||
setPanelOpen(false);
|
if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setPanelOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
@@ -73,7 +81,7 @@ export function AppDateInput({
|
|||||||
document.removeEventListener('mousedown', onPointerDown);
|
document.removeEventListener('mousedown', onPointerDown);
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
};
|
};
|
||||||
}, [panelOpen]);
|
}, [panelOpen, panelRef]);
|
||||||
|
|
||||||
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
|
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
|
||||||
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
|
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
|
||||||
@@ -154,9 +162,19 @@ export function AppDateInput({
|
|||||||
|
|
||||||
{panelOpen && !disabled ? (
|
{panelOpen && !disabled ? (
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
id={`${panelId}-parts`}
|
id={`${panelId}-parts`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
className="absolute start-0 top-full z-50 mt-1 min-w-[17.5rem] w-max max-w-[calc(100vw-1.5rem)] rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
style={
|
||||||
|
panelStyle ?? {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="min-w-[17.5rem] rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||||
>
|
>
|
||||||
<CalendarDayPartsPanel
|
<CalendarDayPartsPanel
|
||||||
panelId={panelId}
|
panelId={panelId}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import React from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
type ButtonClickHandler = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
|
/** External loading (e.g. form-level busy). ORed with auto-pending from async onClick. */
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* If the handler returns a Promise, the button stays loading/disabled until it
|
||||||
|
* settles — prevents double-clicks without a separate busy flag per call site.
|
||||||
|
* Prefer `onClick={() => doThing()}` over `onClick={() => void doThing()}`.
|
||||||
|
*/
|
||||||
|
onClick?: ButtonClickHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
@@ -19,15 +30,20 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
disabled,
|
disabled,
|
||||||
|
onClick,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [autoPending, setAutoPending] = useState(false);
|
||||||
|
const autoPendingRef = useRef(false);
|
||||||
|
|
||||||
|
const pending = isLoading || autoPending;
|
||||||
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
|
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
|
||||||
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
|
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
const variantClasses: Record<ButtonVariant, string> = {
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary: 'bg-primary text-white hover:opacity-90 disabled:opacity-60',
|
||||||
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
|
|
||||||
|
|
||||||
secondary:
|
secondary:
|
||||||
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
|
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
|
||||||
@@ -48,14 +64,34 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const widthClass = fullWidth ? 'w-full' : '';
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
|
const loadingClass = pending ? 'opacity-70 animate-pulse pointer-events-none' : '';
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled || pending || autoPendingRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!onClick) return;
|
||||||
|
|
||||||
|
const result = onClick(event);
|
||||||
|
if (result != null && typeof (result as Promise<void>).then === 'function') {
|
||||||
|
autoPendingRef.current = true;
|
||||||
|
setAutoPending(true);
|
||||||
|
void Promise.resolve(result).finally(() => {
|
||||||
|
autoPendingRef.current = false;
|
||||||
|
setAutoPending(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
|
type="button"
|
||||||
disabled={disabled || isLoading}
|
|
||||||
aria-busy={isLoading || undefined}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
|
||||||
|
disabled={disabled || pending}
|
||||||
|
aria-busy={pending || undefined}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatAppPickerDateLabel } from '@/lib/i18n/format';
|
|||||||
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
||||||
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
|
import { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
|
||||||
|
|
||||||
export type CalendarDaySelectProps = {
|
export type CalendarDaySelectProps = {
|
||||||
value: Date;
|
value: Date;
|
||||||
@@ -50,6 +51,11 @@ export function CalendarDaySelect({
|
|||||||
const panelId = useId();
|
const panelId = useId();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
|
||||||
|
open: panelOpen && !disabled,
|
||||||
|
anchorRef: rootRef,
|
||||||
|
align: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
const normalizedValue = startOfLocalDay(value);
|
const normalizedValue = startOfLocalDay(value);
|
||||||
const today = startOfLocalDay(new Date());
|
const today = startOfLocalDay(new Date());
|
||||||
@@ -63,10 +69,12 @@ export function CalendarDaySelect({
|
|||||||
if (!panelOpen) return;
|
if (!panelOpen) return;
|
||||||
|
|
||||||
function onPointerDown(event: MouseEvent) {
|
function onPointerDown(event: MouseEvent) {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) {
|
const target = event.target as Node;
|
||||||
setPanelOpen(false);
|
if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||||
onBlur?.();
|
return;
|
||||||
}
|
}
|
||||||
|
setPanelOpen(false);
|
||||||
|
onBlur?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
@@ -82,7 +90,7 @@ export function CalendarDaySelect({
|
|||||||
document.removeEventListener('mousedown', onPointerDown);
|
document.removeEventListener('mousedown', onPointerDown);
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
};
|
};
|
||||||
}, [onBlur, panelOpen]);
|
}, [onBlur, panelOpen, panelRef]);
|
||||||
|
|
||||||
const triggerButton = (
|
const triggerButton = (
|
||||||
<button
|
<button
|
||||||
@@ -166,17 +174,27 @@ export function CalendarDaySelect({
|
|||||||
|
|
||||||
{panelOpen && !disabled ? (
|
{panelOpen && !disabled ? (
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
id={panelId}
|
id={panelId}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t('chooseDate')}
|
aria-label={t('chooseDate')}
|
||||||
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
style={
|
||||||
|
panelStyle ?? {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||||
>
|
>
|
||||||
<CalendarDayPartsPanel
|
<CalendarDayPartsPanel
|
||||||
panelId={panelId}
|
panelId={panelId}
|
||||||
value={normalizedValue}
|
value={normalizedValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
closePanelOnDaySelect={closePanelOnDaySelect}
|
closePanelOnDaySelect={closePanelOnDaySelect}
|
||||||
onAfterSelect={(day) => {
|
onAfterSelect={() => {
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
onBlur?.();
|
onBlur?.();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function LanguageToggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
|
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
|
||||||
onClick={() => void switchLocale(option)}
|
onClick={() => switchLocale(option)}
|
||||||
>
|
>
|
||||||
<span>{t(option)}</span>
|
<span>{t(option)}</span>
|
||||||
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
|||||||
@@ -597,39 +597,37 @@ export function StaffPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={copyingInviteMembershipId === lastInviteInfo.membershipId}
|
isLoading={copyingInviteMembershipId === lastInviteInfo.membershipId}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const member = members.find((item) => item.id === lastInviteInfo.membershipId);
|
const member = members.find((item) => item.id === lastInviteInfo.membershipId);
|
||||||
if (member) {
|
if (member) {
|
||||||
void copyStaffInviteLink(member);
|
await copyStaffInviteLink(member);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void (async () => {
|
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
|
||||||
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
|
toast.setError('');
|
||||||
toast.setError('');
|
try {
|
||||||
try {
|
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
||||||
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
if (currentOrganization?.id) {
|
||||||
if (currentOrganization?.id) {
|
const nextLinks = {
|
||||||
const nextLinks = {
|
...pendingInviteLinks,
|
||||||
...pendingInviteLinks,
|
[lastInviteInfo.membershipId]: {
|
||||||
[lastInviteInfo.membershipId]: {
|
membershipId: lastInviteInfo.membershipId,
|
||||||
membershipId: lastInviteInfo.membershipId,
|
email: lastInviteInfo.email,
|
||||||
email: lastInviteInfo.email,
|
invitationUrl: res.data.invitationUrl,
|
||||||
invitationUrl: res.data.invitationUrl,
|
},
|
||||||
},
|
};
|
||||||
};
|
setPendingInviteLinks(nextLinks);
|
||||||
setPendingInviteLinks(nextLinks);
|
writeStoredInviteLinks(currentOrganization.id, nextLinks);
|
||||||
writeStoredInviteLinks(currentOrganization.id, nextLinks);
|
|
||||||
}
|
|
||||||
setLastInviteInfo({ ...lastInviteInfo, invitationUrl: res.data.invitationUrl });
|
|
||||||
await navigator.clipboard.writeText(res.data.invitationUrl);
|
|
||||||
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
|
||||||
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
|
||||||
} catch (e) {
|
|
||||||
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
|
|
||||||
} finally {
|
|
||||||
setCopyingInviteMembershipId(null);
|
|
||||||
}
|
}
|
||||||
})();
|
setLastInviteInfo({ ...lastInviteInfo, invitationUrl: res.data.invitationUrl });
|
||||||
|
await navigator.clipboard.writeText(res.data.invitationUrl);
|
||||||
|
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
||||||
|
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
|
||||||
|
} finally {
|
||||||
|
setCopyingInviteMembershipId(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copiedInviteMembershipId === lastInviteInfo.membershipId
|
{copiedInviteMembershipId === lastInviteInfo.membershipId
|
||||||
@@ -661,7 +659,7 @@ export function StaffPage() {
|
|||||||
canShareInviteLink={canShareStaffInviteLink}
|
canShareInviteLink={canShareStaffInviteLink}
|
||||||
canEnable={canEnableStaff}
|
canEnable={canEnableStaff}
|
||||||
canDisable={canDisableStaff}
|
canDisable={canDisableStaff}
|
||||||
onCopyInviteLink={(member) => void copyStaffInviteLink(member)}
|
onCopyInviteLink={(member) => copyStaffInviteLink(member)}
|
||||||
onEnable={setEnableTarget}
|
onEnable={setEnableTarget}
|
||||||
onDisable={setDisableTarget}
|
onDisable={setDisableTarget}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
@@ -747,7 +745,7 @@ export function StaffPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
|
||||||
disabled={copyingInviteMembershipId === m.id}
|
disabled={copyingInviteMembershipId === m.id}
|
||||||
onClick={() => void copyStaffInviteLink(m)}
|
onClick={() => copyStaffInviteLink(m)}
|
||||||
aria-label={t('copyInviteLink')}
|
aria-label={t('copyInviteLink')}
|
||||||
title={t('copyInviteLinkTitle')}
|
title={t('copyInviteLinkTitle')}
|
||||||
>
|
>
|
||||||
@@ -930,7 +928,7 @@ export function StaffPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
isLoading={inviteLoading}
|
isLoading={inviteLoading}
|
||||||
disabled={!inviteEmail.trim() || !inviteName.trim()}
|
disabled={!inviteEmail.trim() || !inviteName.trim()}
|
||||||
onClick={() => void submitInvite(false)}
|
onClick={() => submitInvite(false)}
|
||||||
>
|
>
|
||||||
{t('sendInvite')}
|
{t('sendInvite')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -941,7 +939,7 @@ export function StaffPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
isLoading={inviteLoading}
|
isLoading={inviteLoading}
|
||||||
onClick={() => void submitInvite(false)}
|
onClick={() => submitInvite(false)}
|
||||||
>
|
>
|
||||||
{t('skipForNow')}
|
{t('skipForNow')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -949,7 +947,7 @@ export function StaffPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
isLoading={inviteLoading}
|
isLoading={inviteLoading}
|
||||||
disabled={Boolean(inviteHoursValidationError)}
|
disabled={Boolean(inviteHoursValidationError)}
|
||||||
onClick={() => void submitInvite(true)}
|
onClick={() => submitInvite(true)}
|
||||||
>
|
>
|
||||||
{t('sendInvite')}
|
{t('sendInvite')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1004,7 +1002,7 @@ export function StaffPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
isLoading={enablingMembershipId === enableTarget.id}
|
isLoading={enablingMembershipId === enableTarget.id}
|
||||||
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
|
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
|
||||||
onClick={() => void confirmEnableMember()}
|
onClick={() => confirmEnableMember()}
|
||||||
>
|
>
|
||||||
{t('enableMemberButton')}
|
{t('enableMemberButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1054,7 +1052,7 @@ export function StaffPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
isLoading={disablingMembershipId === disableTarget.id}
|
isLoading={disablingMembershipId === disableTarget.id}
|
||||||
disabled={Boolean(disablingMembershipId)}
|
disabled={Boolean(disablingMembershipId)}
|
||||||
onClick={() => void confirmDisableMember()}
|
onClick={() => confirmDisableMember()}
|
||||||
>
|
>
|
||||||
{t('disableMemberButton')}
|
{t('disableMemberButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1136,7 +1134,7 @@ export function StaffPage() {
|
|||||||
{tCommon('next')}
|
{tCommon('next')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
|
<Button type="button" isLoading={editLoading} onClick={() => submitEdit()}>
|
||||||
{tCommon('save')}
|
{tCommon('save')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -1145,7 +1143,7 @@ export function StaffPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
isLoading={editLoading}
|
isLoading={editLoading}
|
||||||
disabled={Boolean(editHoursValidationError)}
|
disabled={Boolean(editHoursValidationError)}
|
||||||
onClick={() => void submitEdit()}
|
onClick={() => submitEdit()}
|
||||||
>
|
>
|
||||||
{tCommon('save')}
|
{tCommon('save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function DetailLabCaseCommentsSection({
|
|||||||
<div className="border-t border-border/60 pt-4">
|
<div className="border-t border-border/60 pt-4">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={labCaseId}
|
caseId={labCaseId}
|
||||||
|
viewerSide="CLINIC"
|
||||||
canPost={canPost}
|
canPost={canPost}
|
||||||
canToggleVisibility={false}
|
canToggleVisibility={false}
|
||||||
deferSubmit={deferSubmit}
|
deferSubmit={deferSubmit}
|
||||||
|
|||||||
@@ -144,91 +144,122 @@ export function FdiToothChart({
|
|||||||
return accent ? { color: accent } : undefined;
|
return accent ? { color: accent } : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
/** Dots + thin links — only for Shift-connected spans (not plain singles). */
|
||||||
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
|
const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
|
||||||
{teeth.map((fdi, i) => {
|
const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
|
||||||
const kind = getToothShapeKind(fdi);
|
if (!hasConnected) return null;
|
||||||
const gid = `${uid}-g-${fdi}-${i}`;
|
|
||||||
const isSel = selected.has(fdi);
|
|
||||||
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
|
||||||
const size = toothSizeClass(fdi);
|
|
||||||
const tweak = TOOTH_TWEAKS[fdi];
|
|
||||||
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
|
||||||
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
|
|
||||||
const alignItems = upper ? 'items-end' : 'items-start';
|
|
||||||
const accent = toothAccent(fdi);
|
|
||||||
|
|
||||||
const glyph = (
|
return (
|
||||||
<ToothGlyph
|
<div
|
||||||
fdi={fdi}
|
className={`flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit ${upper ? 'mb-1' : 'mt-1'}`}
|
||||||
kind={kind}
|
aria-hidden
|
||||||
gradientId={gid}
|
>
|
||||||
selected={isSel}
|
{teeth.map((fdi, i) => {
|
||||||
upper={upper}
|
const size = toothSizeClass(fdi);
|
||||||
mirrored={quadrantMirrored(fdi)}
|
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||||
upsideDown={upper}
|
const next = teeth[i + 1];
|
||||||
className={tweak?.glyph ?? size.glyph}
|
const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next));
|
||||||
accentColor={isSel ? accent : undefined}
|
return (
|
||||||
/>
|
<div
|
||||||
);
|
key={`c-${fdi}`}
|
||||||
|
className={`relative flex h-2.5 items-center justify-center ${size.wrapper}`}
|
||||||
const connectedDot = isConnected ? (
|
title={isConnected ? t('toothConnectedHint') : undefined}
|
||||||
<span
|
>
|
||||||
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
|
{linkToNext ? (
|
||||||
aria-hidden
|
<span
|
||||||
title={t('toothConnectedHint')}
|
className="pointer-events-none absolute top-1/2 left-1/2 z-0 h-[2px] -translate-y-1/2 bg-primary/75"
|
||||||
/>
|
style={{ width: 'calc(100% + 0.25rem)' }}
|
||||||
) : (
|
/>
|
||||||
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
|
) : null}
|
||||||
);
|
{isConnected ? (
|
||||||
|
<span className="relative z-10 block h-2 w-2 rounded-full bg-primary shadow-sm" />
|
||||||
return (
|
|
||||||
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
|
||||||
{upper ? connectedDot : null}
|
|
||||||
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
|
||||||
{interactive ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isDisabled}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
|
||||||
if (e.shiftKey) e.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
|
||||||
}}
|
|
||||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
|
||||||
className={`
|
|
||||||
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
|
||||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
|
||||||
`}
|
|
||||||
aria-pressed={isSel}
|
|
||||||
aria-label={
|
|
||||||
isSel
|
|
||||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
|
||||||
isConnected ? t('toothConnectedSuffix') : ''
|
|
||||||
}`
|
|
||||||
: t('toothAria', { fdi })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{glyph}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<span className="block h-2 w-2" />
|
||||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
|
||||||
className="rounded-[var(--radius-sm)] p-0.5"
|
|
||||||
aria-hidden={!isSel}
|
|
||||||
>
|
|
||||||
{glyph}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!upper ? connectedDot : null}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
||||||
|
<div className="flex flex-col items-center min-w-max mx-auto w-fit">
|
||||||
|
{upper ? <ConnectedRail teeth={teeth} upper /> : null}
|
||||||
|
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
|
||||||
|
{teeth.map((fdi, i) => {
|
||||||
|
const kind = getToothShapeKind(fdi);
|
||||||
|
const gid = `${uid}-g-${fdi}-${i}`;
|
||||||
|
const isSel = selected.has(fdi);
|
||||||
|
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||||
|
const size = toothSizeClass(fdi);
|
||||||
|
const tweak = TOOTH_TWEAKS[fdi];
|
||||||
|
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
||||||
|
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
|
||||||
|
const alignItems = upper ? 'items-end' : 'items-start';
|
||||||
|
const accent = toothAccent(fdi);
|
||||||
|
|
||||||
|
const glyph = (
|
||||||
|
<ToothGlyph
|
||||||
|
fdi={fdi}
|
||||||
|
kind={kind}
|
||||||
|
gradientId={gid}
|
||||||
|
selected={isSel}
|
||||||
|
upper={upper}
|
||||||
|
mirrored={quadrantMirrored(fdi)}
|
||||||
|
upsideDown={upper}
|
||||||
|
className={tweak?.glyph ?? size.glyph}
|
||||||
|
accentColor={isSel ? accent : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
||||||
|
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
||||||
|
{interactive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||||
|
if (e.shiftKey) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||||
|
}}
|
||||||
|
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||||
|
className={`
|
||||||
|
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||||
|
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||||
|
`}
|
||||||
|
aria-pressed={isSel}
|
||||||
|
aria-label={
|
||||||
|
isSel
|
||||||
|
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
||||||
|
isConnected ? t('toothConnectedSuffix') : ''
|
||||||
|
}`
|
||||||
|
: t('toothAria', { fdi })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||||
|
className="rounded-[var(--radius-sm)] p-0.5"
|
||||||
|
aria-hidden={!isSel}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!upper ? <ConnectedRail teeth={teeth} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -307,7 +338,7 @@ export function FdiToothChart({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
||||||
{headerControl}
|
{headerControl}
|
||||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
|
||||||
{t('selectedLabel')}{' '}
|
{t('selectedLabel')}{' '}
|
||||||
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -45,19 +45,10 @@ interface LabCasesDispatchPanelProps {
|
|||||||
canInviteLab?: boolean;
|
canInviteLab?: boolean;
|
||||||
onInviteLab?: () => void;
|
onInviteLab?: () => void;
|
||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
onAddLabCase: () => void;
|
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
|
||||||
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
|
|
||||||
onCommentError?: (message: string) => void;
|
onCommentError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const lc of labCases) {
|
|
||||||
if (lc.sentAt && lc.detailClientId) ids.add(lc.detailClientId);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProsthesisGroupRow = {
|
type ProsthesisGroupRow = {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
kind: 'connected' | 'single';
|
kind: 'connected' | 'single';
|
||||||
@@ -129,7 +120,6 @@ export function LabCasesDispatchPanel({
|
|||||||
canInviteLab = false,
|
canInviteLab = false,
|
||||||
onInviteLab,
|
onInviteLab,
|
||||||
sendBusyId,
|
sendBusyId,
|
||||||
onAddLabCase,
|
|
||||||
onSendLabCase,
|
onSendLabCase,
|
||||||
onCommentError,
|
onCommentError,
|
||||||
}: LabCasesDispatchPanelProps) {
|
}: LabCasesDispatchPanelProps) {
|
||||||
@@ -157,8 +147,6 @@ export function LabCasesDispatchPanel({
|
|||||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||||
|
|
||||||
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
||||||
const canAddLabShipment =
|
|
||||||
!detailAlreadyInShipment && !sentDetailClientIds(labCases).has(activeDetailId);
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -289,23 +277,14 @@ export function LabCasesDispatchPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShipmentCardHeader() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 border-b border-border/60 pb-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<p className="text-xs font-semibold text-text-primary">{t('labShipmentIncludedDetails')}</p>
|
|
||||||
{renderDueDateField()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDueDateField() {
|
function renderDueDateField() {
|
||||||
if (!activeLabCase) return null;
|
if (!activeLabCase) return null;
|
||||||
const inputValue = toDateInputValue(activeLabCase.dueDate);
|
const inputValue = toDateInputValue(activeLabCase.dueDate);
|
||||||
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
|
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 sm:text-end">
|
<div className="w-full min-w-0 sm:w-auto sm:shrink-0 sm:text-end">
|
||||||
<div className="flex items-center gap-2 sm:justify-end">
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
<label
|
<label
|
||||||
htmlFor={dueDateInputId}
|
htmlFor={dueDateInputId}
|
||||||
className="text-xs font-medium text-text-secondary shrink-0"
|
className="text-xs font-medium text-text-secondary shrink-0"
|
||||||
@@ -323,7 +302,7 @@ export function LabCasesDispatchPanel({
|
|||||||
onBlur={(committed) => {
|
onBlur={(committed) => {
|
||||||
if (sent) void handleSentDueDateBlur(committed);
|
if (sent) void handleSentDueDateBlur(committed);
|
||||||
}}
|
}}
|
||||||
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md py-1.5 text-sm`}
|
className={`${FORM_SELECT_CLASS} w-full min-w-0 max-w-full sm:max-w-[11rem] rounded-md py-1.5 text-sm`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
||||||
@@ -335,7 +314,6 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
function renderIncludedDetailSummary() {
|
function renderIncludedDetailSummary() {
|
||||||
if (!activeDetail) return null;
|
if (!activeDetail) return null;
|
||||||
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
|
|
||||||
|
|
||||||
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
|
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
|
||||||
return (
|
return (
|
||||||
@@ -375,9 +353,6 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
|
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
|
||||||
<p className="text-text-primary">
|
|
||||||
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
|
|
||||||
</p>
|
|
||||||
{rows.map((g) => (
|
{rows.map((g) => (
|
||||||
<p
|
<p
|
||||||
key={g.groupId}
|
key={g.groupId}
|
||||||
@@ -401,31 +376,19 @@ export function LabCasesDispatchPanel({
|
|||||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-4 space-y-4">
|
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canAddLabShipment && (
|
{detailAlreadyInShipment ? renderDueDateField() : null}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!canEdit || disabled}
|
|
||||||
onClick={onAddLabCase}
|
|
||||||
>
|
|
||||||
{t('addLabShipment')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!detailAlreadyInShipment ? (
|
{activeLabCase ? (
|
||||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
|
||||||
) : activeLabCase ? (
|
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30">
|
|
||||||
{renderShipmentCardHeader()}
|
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<>
|
<>
|
||||||
{renderIncludedDetailSummary()}
|
{renderIncludedDetailSummary()}
|
||||||
@@ -589,39 +552,38 @@ export function LabCasesDispatchPanel({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={row.groupId}
|
key={row.groupId}
|
||||||
className="block text-xs text-text-muted space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
|
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2 min-w-0"
|
||||||
>
|
>
|
||||||
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
|
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
|
||||||
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
<span className="flex flex-wrap items-center gap-2 text-text-secondary min-w-0 break-words">
|
||||||
<span>
|
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
||||||
{row.kind === 'connected'
|
<span className="min-w-0">
|
||||||
? t('prosthesisConnectedLabel')
|
{row.kind === 'connected'
|
||||||
: t('prosthesisColTooth')}
|
? t('prosthesisConnectedLabel')
|
||||||
{': '}
|
: t('prosthesisColTooth')}
|
||||||
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
{': '}
|
||||||
</span>
|
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
||||||
<span className="text-text-muted">
|
</span>
|
||||||
· {t('detailLabel', { n: row.detailNumber })}
|
|
||||||
</span>
|
</span>
|
||||||
|
<select
|
||||||
|
value={current}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
|
||||||
|
aria-label={
|
||||||
|
row.kind === 'connected'
|
||||||
|
? t('prosthesisConnectedLabel')
|
||||||
|
: t('prosthesisColType')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||||
|
{prosthesisOptions.map((opt) => (
|
||||||
|
<option key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</span>
|
</span>
|
||||||
<select
|
|
||||||
value={current}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
|
||||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
|
||||||
aria-label={
|
|
||||||
row.kind === 'connected'
|
|
||||||
? t('prosthesisConnectedLabel')
|
|
||||||
: t('prosthesisColType')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
|
||||||
{prosthesisOptions.map((opt) => (
|
|
||||||
<option key={opt.code} value={opt.code}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ export function TreatmentDetailsEditor({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder={t('commentsPlaceholder')}
|
placeholder={t('commentsPlaceholder')}
|
||||||
rows={5}
|
rows={2}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[60px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,26 @@ function isDetailsDirty(
|
|||||||
return serializeDetails(details) !== savedSnapshot;
|
return serializeDetails(details) !== savedSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply server ids/attachments onto local rows without replacing newer local edits. */
|
||||||
|
function mergeServerIdsIntoDetails(
|
||||||
|
local: TreatmentDetailDraft[],
|
||||||
|
fromServer: TreatmentDetailDraft[],
|
||||||
|
): TreatmentDetailDraft[] {
|
||||||
|
const byClientId = new Map(fromServer.map((d) => [d.clientId, d]));
|
||||||
|
return local.map((d) => {
|
||||||
|
const s = byClientId.get(d.clientId);
|
||||||
|
if (!s) return d;
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
id: s.id ?? d.id,
|
||||||
|
labCaseId: s.labCaseId ?? d.labCaseId,
|
||||||
|
taskProgress: s.taskProgress ?? d.taskProgress,
|
||||||
|
attachmentMetas:
|
||||||
|
d.attachmentMetas.length > 0 ? d.attachmentMetas : s.attachmentMetas,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function detailsToPreviewTreatment(
|
function detailsToPreviewTreatment(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
|
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
|
||||||
@@ -386,6 +406,8 @@ export function TreatmentWorkspace({
|
|||||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const historyRequestRef = useRef(0);
|
const historyRequestRef = useRef(0);
|
||||||
|
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
||||||
|
const pendingEntryStepRef = useRef<EntryStep | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingAppointmentIdRef.current = initialAppointmentId;
|
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||||
@@ -612,10 +634,13 @@ export function TreatmentWorkspace({
|
|||||||
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
||||||
|
|
||||||
// Reset whole-plan overview when switching details.
|
// Reset whole-plan overview when switching details.
|
||||||
|
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowWholeTreatmentPlan(false);
|
setShowWholeTreatmentPlan(false);
|
||||||
rangeAnchorRef.current = null;
|
rangeAnchorRef.current = null;
|
||||||
setEntryStep('teeth');
|
const pending = pendingEntryStepRef.current;
|
||||||
|
pendingEntryStepRef.current = null;
|
||||||
|
setEntryStep(pending ?? 'teeth');
|
||||||
}, [activeDetailId]);
|
}, [activeDetailId]);
|
||||||
|
|
||||||
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||||
@@ -926,24 +951,40 @@ export function TreatmentWorkspace({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const mapped = response.data.details.map(mapDetailFromApi);
|
const mapped = response.data.details.map(mapDetailFromApi);
|
||||||
setDetails(mapped);
|
const sentSnapshot = serializeDetails(currentDetails);
|
||||||
setActiveDetailId((prev) => {
|
const localNow = detailsRef.current;
|
||||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
|
||||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
// If the user changed details while this save was in flight (e.g. removed a
|
||||||
});
|
// detail), do not clobber local state with the stale response.
|
||||||
setSavedSnapshot(serializeDetails(mapped));
|
if (serializeDetails(localNow) === sentSnapshot) {
|
||||||
|
setDetails(mapped);
|
||||||
|
setActiveDetailId((prev) => {
|
||||||
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||||
|
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||||
|
});
|
||||||
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
|
} else {
|
||||||
|
const merged = mergeServerIdsIntoDetails(localNow, mapped);
|
||||||
|
detailsRef.current = merged;
|
||||||
|
setDetails(merged);
|
||||||
|
setActiveDetailId((prev) => {
|
||||||
|
const stillExists = merged.some((d) => d.clientId === prev);
|
||||||
|
return stillExists ? prev : merged[0]?.clientId ?? prev;
|
||||||
|
});
|
||||||
|
// Keep dirty so the queued autosave persists the newer local state.
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[selectedAppointment, t],
|
[selectedAppointment, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshHistory = useCallback(async (patientId: string) => {
|
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
|
||||||
const requestId = ++historyRequestRef.current;
|
const requestId = ++historyRequestRef.current;
|
||||||
try {
|
try {
|
||||||
const response = await treatmentsApi.listPatientHistory(patientId, 50);
|
const response = await treatmentsApi.listPatientHistory(patientId, 50);
|
||||||
if (requestId !== historyRequestRef.current) return;
|
if (requestId !== historyRequestRef.current) return;
|
||||||
setHistory(response.data);
|
setHistory(response.data);
|
||||||
void refreshPatientLabCases(patientId);
|
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (requestId !== historyRequestRef.current) return;
|
if (requestId !== historyRequestRef.current) return;
|
||||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||||
@@ -1118,10 +1159,16 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
skipNextGetDraftRef.current = true;
|
skipNextGetDraftRef.current = true;
|
||||||
draftHydratingRef.current = true;
|
draftHydratingRef.current = true;
|
||||||
|
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
|
||||||
|
pendingEntryStepRef.current = 'lab';
|
||||||
|
}
|
||||||
hydrateFromTreatment(treatmentToLoad);
|
hydrateFromTreatment(treatmentToLoad);
|
||||||
draftHydratingRef.current = false;
|
draftHydratingRef.current = false;
|
||||||
|
|
||||||
if (focusDetailClientId) {
|
if (focusDetailClientId) {
|
||||||
|
if (options?.scrollToLabPanel !== false) {
|
||||||
|
pendingEntryStepRef.current = 'lab';
|
||||||
|
}
|
||||||
setActiveDetailId(focusDetailClientId);
|
setActiveDetailId(focusDetailClientId);
|
||||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||||
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
|
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||||
@@ -1186,6 +1233,7 @@ export function TreatmentWorkspace({
|
|||||||
(item: LabDispatchAttentionItem) => {
|
(item: LabDispatchAttentionItem) => {
|
||||||
if (item.isCurrentDraft) {
|
if (item.isCurrentDraft) {
|
||||||
exitBrowse();
|
exitBrowse();
|
||||||
|
pendingEntryStepRef.current = 'lab';
|
||||||
setActiveDetailId(item.detailClientId);
|
setActiveDetailId(item.detailClientId);
|
||||||
const linked = labCaseDrafts.find(
|
const linked = labCaseDrafts.find(
|
||||||
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
||||||
@@ -1267,12 +1315,21 @@ export function TreatmentWorkspace({
|
|||||||
workspaceMode === 'live' &&
|
workspaceMode === 'live' &&
|
||||||
!isBrowsing
|
!isBrowsing
|
||||||
) {
|
) {
|
||||||
|
pendingEntryStepRef.current = 'lab';
|
||||||
setActiveDetailId(item.detailClientId);
|
setActiveDetailId(item.detailClientId);
|
||||||
|
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
||||||
|
if (matchingDraft) {
|
||||||
|
setActiveLabCaseId(matchingDraft.clientId);
|
||||||
|
}
|
||||||
|
setEntryStep('lab');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
|
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
|
||||||
scrollToLabPanel: false,
|
scrollToLabPanel: true,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
@@ -1283,6 +1340,7 @@ export function TreatmentWorkspace({
|
|||||||
history,
|
history,
|
||||||
historyPanelItems,
|
historyPanelItems,
|
||||||
isBrowsing,
|
isBrowsing,
|
||||||
|
labCaseDrafts,
|
||||||
loadTreatmentIntoWorkspace,
|
loadTreatmentIntoWorkspace,
|
||||||
selectedAppointment?.id,
|
selectedAppointment?.id,
|
||||||
selectedAppointmentId,
|
selectedAppointmentId,
|
||||||
@@ -1455,6 +1513,9 @@ export function TreatmentWorkspace({
|
|||||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
||||||
nextDetails[0]?.clientId)
|
nextDetails[0]?.clientId)
|
||||||
: activeDetailId;
|
: activeDetailId;
|
||||||
|
|
||||||
|
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
||||||
|
detailsRef.current = nextDetails;
|
||||||
setDetails(nextDetails);
|
setDetails(nextDetails);
|
||||||
if (nextActive) setActiveDetailId(nextActive);
|
if (nextActive) setActiveDetailId(nextActive);
|
||||||
|
|
||||||
@@ -1546,6 +1607,31 @@ export function TreatmentWorkspace({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryStep !== 'lab') return;
|
||||||
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
|
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||||
|
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
|
||||||
|
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
|
||||||
|
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
|
||||||
|
if (isDetailLocked(activeDetail)) return;
|
||||||
|
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
|
||||||
|
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
|
||||||
|
if (hasDraft) return;
|
||||||
|
void handleAddLabCase();
|
||||||
|
}, [
|
||||||
|
activeDetailId,
|
||||||
|
canEditTreatmentForDay,
|
||||||
|
details,
|
||||||
|
entryStep,
|
||||||
|
handleAddLabCase,
|
||||||
|
isDetailLocked,
|
||||||
|
labCaseDrafts,
|
||||||
|
labDependentCodes,
|
||||||
|
selectedAppointment,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSendLabCase = useCallback(
|
const handleSendLabCase = useCallback(
|
||||||
async (labCase: LabCaseDraft, comment?: string) => {
|
async (labCase: LabCaseDraft, comment?: string) => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
@@ -1586,12 +1672,17 @@ export function TreatmentWorkspace({
|
|||||||
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
||||||
if (draftResponse.data?.details?.length) {
|
if (draftResponse.data?.details?.length) {
|
||||||
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
||||||
|
detailsRef.current = mapped;
|
||||||
setDetails(mapped);
|
setDetails(mapped);
|
||||||
|
setActiveDetailId((prev) => {
|
||||||
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||||
|
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||||
|
});
|
||||||
setSavedSnapshot(serializeDetails(mapped));
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
} else {
|
} else {
|
||||||
const sentDetailClientId = labCase.detailClientId;
|
const sentDetailClientId = labCase.detailClientId;
|
||||||
setDetails((prev) =>
|
setDetails((prev) => {
|
||||||
prev.map((detail) => {
|
const next = prev.map((detail) => {
|
||||||
if (detail.clientId !== sentDetailClientId) return detail;
|
if (detail.clientId !== sentDetailClientId) return detail;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
@@ -1602,8 +1693,10 @@ export function TreatmentWorkspace({
|
|||||||
? [response.data.destinationOrganizationId]
|
? [response.data.destinationOrganizationId]
|
||||||
: detail.sendToOrganizationIds,
|
: detail.sendToOrganizationIds,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
detailsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setLabCaseDrafts((prev) =>
|
setLabCaseDrafts((prev) =>
|
||||||
@@ -1615,6 +1708,7 @@ export function TreatmentWorkspace({
|
|||||||
sentAt: response.data.sentAt,
|
sentAt: response.data.sentAt,
|
||||||
destinationOrganizationId: response.data.destinationOrganizationId,
|
destinationOrganizationId: response.data.destinationOrganizationId,
|
||||||
sends: response.data.sends,
|
sends: response.data.sends,
|
||||||
|
taskProgress: response.data.taskProgress ?? lc.taskProgress,
|
||||||
}
|
}
|
||||||
: lc,
|
: lc,
|
||||||
),
|
),
|
||||||
@@ -1627,8 +1721,8 @@ export function TreatmentWorkspace({
|
|||||||
showSuccess(t('successCaseSent'));
|
showSuccess(t('successCaseSent'));
|
||||||
notifyTabBadgesChanged();
|
notifyTabBadgesChanged();
|
||||||
if (selectedAppointment.patientId) {
|
if (selectedAppointment.patientId) {
|
||||||
void refreshPatientLabCases(selectedAppointment.patientId);
|
// Silent: avoid rail/history loading flicker while staying on Lab step.
|
||||||
void refreshHistory(selectedAppointment.patientId);
|
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
||||||
@@ -1641,7 +1735,6 @@ export function TreatmentWorkspace({
|
|||||||
selectedAppointment,
|
selectedAppointment,
|
||||||
persistDraft,
|
persistDraft,
|
||||||
persistLabCases,
|
persistLabCases,
|
||||||
refreshPatientLabCases,
|
|
||||||
refreshHistory,
|
refreshHistory,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
showError,
|
showError,
|
||||||
@@ -2054,8 +2147,7 @@ export function TreatmentWorkspace({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
sendBusyId={sendBusyId}
|
sendBusyId={sendBusyId}
|
||||||
onAddLabCase={() => void handleAddLabCase()}
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
||||||
onSendLabCase={(lc, comment) => void 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')}
|
||||||
|
|||||||
96
frontend/src/lib/hooks/useAnchoredPanelPosition.ts
Normal file
96
frontend/src/lib/hooks/useAnchoredPanelPosition.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
||||||
|
|
||||||
|
const VIEWPORT_PADDING = 12;
|
||||||
|
const DEFAULT_GAP = 8;
|
||||||
|
|
||||||
|
export type AnchoredPanelAlign = 'start' | 'stretch';
|
||||||
|
|
||||||
|
type UseAnchoredPanelPositionArgs = {
|
||||||
|
open: boolean;
|
||||||
|
/** Element the panel should attach to (field / trigger shell). */
|
||||||
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
|
/** Prefer aligning the panel's inline-start with the anchor's inline-start. */
|
||||||
|
align?: AnchoredPanelAlign;
|
||||||
|
gap?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions a panel with `position: fixed`, clamped to the viewport.
|
||||||
|
* Flips above the anchor when there is not enough room below.
|
||||||
|
* Handles LTR/RTL via `document.documentElement.dir`.
|
||||||
|
*/
|
||||||
|
export function useAnchoredPanelPosition({
|
||||||
|
open,
|
||||||
|
anchorRef,
|
||||||
|
align = 'start',
|
||||||
|
gap = DEFAULT_GAP,
|
||||||
|
}: UseAnchoredPanelPositionArgs) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [style, setStyle] = useState<CSSProperties | undefined>(undefined);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setStyle(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!anchor || !panel) return;
|
||||||
|
|
||||||
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
|
const panelRect = panel.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const isRtl = document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
|
let width =
|
||||||
|
align === 'stretch'
|
||||||
|
? Math.min(anchorRect.width, vw - VIEWPORT_PADDING * 2)
|
||||||
|
: Math.min(panelRect.width || panel.offsetWidth, vw - VIEWPORT_PADDING * 2);
|
||||||
|
|
||||||
|
if (width < 1) width = Math.min(280, vw - VIEWPORT_PADDING * 2);
|
||||||
|
|
||||||
|
let left: number;
|
||||||
|
if (align === 'stretch') {
|
||||||
|
left = anchorRect.left;
|
||||||
|
} else if (isRtl) {
|
||||||
|
left = anchorRect.right - width;
|
||||||
|
} else {
|
||||||
|
left = anchorRect.left;
|
||||||
|
}
|
||||||
|
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - width - VIEWPORT_PADDING));
|
||||||
|
|
||||||
|
const height = panelRect.height || panel.offsetHeight;
|
||||||
|
let top = anchorRect.bottom + gap;
|
||||||
|
if (top + height > vh - VIEWPORT_PADDING) {
|
||||||
|
const above = anchorRect.top - height - gap;
|
||||||
|
top = above >= VIEWPORT_PADDING ? above : Math.max(VIEWPORT_PADDING, vh - height - VIEWPORT_PADDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
zIndex: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
// Second pass after fonts/layout settle panel size.
|
||||||
|
const raf = window.requestAnimationFrame(update);
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
window.addEventListener('scroll', update, true);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('scroll', update, true);
|
||||||
|
};
|
||||||
|
}, [align, anchorRef, gap, open]);
|
||||||
|
|
||||||
|
return { panelRef, style };
|
||||||
|
}
|
||||||
50
frontend/src/lib/hooks/useAsyncAction.ts
Normal file
50
frontend/src/lib/hooks/useAsyncAction.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an async action at most once at a time (sync re-entry guard via ref).
|
||||||
|
* Use for API mutations from buttons/controls that are not the shared Button,
|
||||||
|
* or when several controls share one pending flag.
|
||||||
|
*/
|
||||||
|
export function useAsyncAction() {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const pendingRef = useRef(false);
|
||||||
|
|
||||||
|
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
|
||||||
|
if (pendingRef.current) return undefined;
|
||||||
|
pendingRef.current = true;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
pendingRef.current = false;
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pending, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as useAsyncAction, but tracks which row/id is in flight so list UIs
|
||||||
|
* can disable sibling actions while one mutation runs.
|
||||||
|
*/
|
||||||
|
export function useAsyncActionById() {
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
const pendingIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const run = useCallback(async <T,>(id: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||||
|
if (pendingIdRef.current) return undefined;
|
||||||
|
pendingIdRef.current = id;
|
||||||
|
setPendingId(id);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
pendingIdRef.current = null;
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pendingId, run };
|
||||||
|
}
|
||||||
@@ -354,6 +354,12 @@ select option {
|
|||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opt out when an icon sits on a primary/filled control (Send, etc.) */
|
||||||
|
.lucide.lucide-on-primary {
|
||||||
|
color: #fff;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes task-row-complete-exit {
|
@keyframes task-row-complete-exit {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user