diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc new file mode 100644 index 0000000..ee9abdf --- /dev/null +++ b/.cursor/rules/appointments.mdc @@ -0,0 +1,14 @@ +--- +description: Clinic appointments — delete/patient locks when treatment linked +globs: frontend/src/components/ui/appointments/**,frontend/src/components/appointments/**,backend/src/modules/appointments/** +alwaysApply: false +--- + +# Appointments + +- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`). +- **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. +- **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. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index 47c0d4f..d3d47c2 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -37,3 +37,7 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo ## Lab case share link QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`. + +## Notifications (inbox + live tabs) + +Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`. diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc index f338867..a523428 100644 --- a/.cursor/rules/frontend-components.mdc +++ b/.cursor/rules/frontend-components.mdc @@ -60,5 +60,6 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork | Filter or inline `` — one component for all locales **Select chevron** diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md index 9be6bbc..fe237c2 100644 --- a/.cursor/skills/lab-case-share-link/SKILL.md +++ b/.cursor/skills/lab-case-share-link/SKILL.md @@ -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). -**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) diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 586f6e6..202740a 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -12,8 +12,10 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## List behavior - **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. - **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`) @@ -34,10 +36,45 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task ## Detail panel - 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) +- FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`). +- **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`. +- **Case Sheet PDF** — see below. - **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`. +## Case Sheet PDF + +**Entry:** Download PDF beside Comments on `CaseDetailPanel`. +**Builder:** [`caseSheetPdf.ts`](frontend/src/components/lab/caseSheetPdf.ts) → off-screen [`CaseSheetPrintLayout.tsx`](frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx) → `html2canvas` → `jspdf` A4 (ISO √2 — prints cleanly onto A3–A6). + +### html2canvas + Tailwind v4 (required) + +Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not** mount live `FdiToothChart` / Tailwind color classes into the capture tree. + +| Do | Don't | +|----|--------| +| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture | +| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF | +| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture | +| Prosthesis labels from **catalog** (then task label) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) | + +### Layout conventions + +- Sections: meta → parties → **Prosthesis** rows → **Tooth Chart** heading *above* the horizontal rule → chart → Comments. +- Connected badge: bold + underline (no border box — html2canvas centering is unreliable). +- Order number: `externalCode.trim()` when set, else short case id (`id` without hyphens, first 8, uppercased). +- Share QR when `shareUrl` present. + +### API + +- `LabCase.externalCode` (optional string, max 64) +- `PATCH /cases/:id/external-code` `{ externalCode: string | null }` — same edit permission as important flag + +## Live soft refresh + +Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent `loadCases` + selected `loadDetail` (keeps filters/selection; no full remount). Same event refreshes sidebar Cases badge via `useTabBadgeCounts`. See `.cursor/skills/notifications-inbox/SKILL.md`. + ## Permissions `TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes. diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 425d0ba..22e2915 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -26,7 +26,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker 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 @@ -66,7 +66,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. ## APIs - `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/locate-page` — page number for a task in the sorted filtered list - `GET /cases/assignable-staff` — staff eligible for task assignment @@ -91,9 +91,11 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t - **Tasks filters:** filter `` via `ChartCard` `headerAction` (no taller widgets). Periods persist in `localStorage` (`chart-periods.ts`) and are sent on `GET /today/summary`. + +| Chart | Periods | Direction | +|-------|---------|-----------| +| Appointments (all / mine), lab task activity, cases due | week, month | lookback (cases due = **forward**) | +| Treatment mix, case partners, efficiency | week, month, year | lookback | + +Do **not** put year on day-series charts. Backend: `daysForChartPeriod` + `buildLocalDayBuckets` in `today.service.ts`. ## Permissions (task KPIs) diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index ddcf835..2b37d1a 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -2,7 +2,7 @@ name: dyolink-treatment-workspace -description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. --- @@ -28,7 +28,40 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen 3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) -4. **Editor** — `TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx` +4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel` + + + +## Entry wizard (Teeth / Content / Lab) + + + +Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps. + +| Step | UI | Notes | +|------|-----|--------| +| **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** | +| **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). +- 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. + + + +## Tooth selection groups + + + +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** 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 → 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`. +- 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). @@ -91,15 +124,16 @@ Saved lab-dependent detail with **no teeth** can autosave but **cannot** create ## Lab case comments on details +Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes. - -Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed): - -- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`. -- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints. +- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab). +- Sent / in progress: live composer until tasks complete (`canPostComments`). +- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`). - Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`. +## Live lab rail refresh +Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent refresh of patient lab cases + unread rail. Does **not** remount the workspace or clear draft/form state. See `.cursor/skills/notifications-inbox/SKILL.md`. ## Appointments default selection @@ -161,17 +195,27 @@ Draft writes require provider match (`ensureAppointmentProvider`) unless org own ## Edit gating - - ```typescript - canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' - ``` +Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. +### Per-detail sent lock + +`isDetailLocked` = linked lab case has `sentAt`. Locked details: type, comment, teeth, attachments are read-only (UI + draft save skips updates/deletes; attachment upload returns `TREATMENT_DETAIL_SENT`). + +### Add / remove details + +- **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent. +- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`). Same gates as before: only when `details.length > 1`; disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. **Do not** put a delete control in the Content wizard step. + +### Sent lab shipment fields (vs detail) + +After send, destination / prosthesis / shipment attachments are frozen. **Due date** stays editable until all lab tasks complete (UI still respects day/mode `disabled`). **Comments** stay editable until tasks complete and intentionally ignore the day/mode gate. + +Full map helpers: `treatmentDetailRules.ts`, `LabCasesDispatchPanel.tsx`. -## Optional fields diff --git a/AGENTS.md b/AGENTS.md index e248ce1..6c672fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,16 +42,27 @@ frontend/src/ **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. +**Treatment edit / details (quick ref):** +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. +- 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); 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 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):** - 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. - **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). -- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. +- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). +- **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`. -**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`. +**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 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 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) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/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. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. **Lab case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. @@ -82,11 +93,12 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | -| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | -| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | +| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, Case Sheet PDF, external code | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | -| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors | +| `.cursor/skills/tab-badges/` | Sidebar tab badges: Cases/Tasks/Treatment, LabCaseActivity, tab-counts API, read cursors | +| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime | | `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | diff --git a/backend/package-lock.json b/backend/package-lock.json index 8757df9..e4c3c85 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,8 +20,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.28", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.28", "@prisma/client": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", @@ -41,10 +43,9 @@ "passport-local": "^1.0.0", "pg": "^8.18.0", "prisma": "^6.19.2", - "qrcode": "^1.5.4", - "react-qr-code": "^2.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "styled-components": "^6.3.11", "swagger-ui-express": "^5.0.1" }, @@ -61,9 +62,6 @@ "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", - "@types/qrcode": "^1.5.5", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "@types/styled-components": "^5.1.36", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", @@ -4485,6 +4483,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.28", + "resolved": "https://registry.npmmirror.com/@nestjs/platform-socket.io/-/platform-socket.io-11.1.28.tgz", + "integrity": "sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -4580,6 +4597,29 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.28", + "resolved": "https://registry.npmmirror.com/@nestjs/websockets/-/websockets-11.1.28.tgz", + "integrity": "sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -5320,6 +5360,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6088,6 +6134,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6293,16 +6348,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/qrcode": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz", - "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -6325,16 +6370,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -6424,6 +6459,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -7553,6 +7597,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7562,6 +7607,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7893,6 +7939,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", @@ -8174,6 +8229,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8451,6 +8507,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8463,6 +8520,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -8785,15 +8843,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -8907,12 +8956,6 @@ "node": ">=0.3.1" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -9031,6 +9074,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -9051,6 +9095,79 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -10074,6 +10191,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10691,6 +10809,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12646,6 +12765,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12895,6 +13023,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13208,15 +13337,6 @@ "node": ">=4" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -13635,133 +13755,6 @@ ], "license": "MIT" }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode-generator": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz", - "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", - "license": "MIT" - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -13971,19 +13964,6 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, - "node_modules/react-qr-code": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz", - "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.8.1", - "qrcode-generator": "^2.0.4" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -14209,6 +14189,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14224,12 +14205,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -14514,12 +14489,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14664,6 +14633,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14826,6 +14879,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14856,6 +14910,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -16346,12 +16401,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16373,6 +16422,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -16422,6 +16472,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", diff --git a/backend/package.json b/backend/package.json index 9f1e5a4..0346056 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,8 +41,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.28", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.28", "@prisma/client": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", @@ -64,6 +66,7 @@ "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "styled-components": "^6.3.11", "swagger-ui-express": "^5.0.1" }, diff --git a/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql b/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql new file mode 100644 index 0000000..f7d82ed --- /dev/null +++ b/backend/prisma/migrations/20260716120000_tooth_selection_groups/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "treatment_details" ADD COLUMN "toothSelectionGroups" JSONB; + +-- AlterTable +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT ''; + +-- AlterTable +ALTER TABLE "lab_case_tasks" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT ''; + +-- DropIndex +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key" ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql b/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql new file mode 100644 index 0000000..bbab5ea --- /dev/null +++ b/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql @@ -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"); diff --git a/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql b/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql new file mode 100644 index 0000000..cd5edf0 --- /dev/null +++ b/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql @@ -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"); diff --git a/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql b/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql new file mode 100644 index 0000000..9c9d9ad --- /dev/null +++ b/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql @@ -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"); diff --git a/backend/prisma/migrations/20260718120000_user_notifications/migration.sql b/backend/prisma/migrations/20260718120000_user_notifications/migration.sql new file mode 100644 index 0000000..faca011 --- /dev/null +++ b/backend/prisma/migrations/20260718120000_user_notifications/migration.sql @@ -0,0 +1,41 @@ +-- CreateEnum +CREATE TYPE "UserNotificationType" AS ENUM ( + 'CASE_SENT', + 'CLINIC_COMMENT', + 'LAB_COMMENT', + 'LAB_COMMENT_CLINIC', + 'CASE_IMPORTANT', + 'TASK_COMPLETED', + 'TASK_ASSIGNED', + 'CONNECTION_REQUEST', + 'STAFF_INVITE' +); + +-- CreateTable +CREATE TABLE "user_notifications" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "type" "UserNotificationType" NOT NULL, + "actorUserId" TEXT, + "payload" JSONB, + "href" TEXT NOT NULL, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_notifications_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "user_notifications_userId_organizationId_createdAt_idx" + ON "user_notifications"("userId", "organizationId", "createdAt"); + +CREATE INDEX "user_notifications_userId_organizationId_readAt_idx" + ON "user_notifications"("userId", "organizationId", "readAt"); + +ALTER TABLE "user_notifications" + ADD CONSTRAINT "user_notifications_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "user_notifications" + ADD CONSTRAINT "user_notifications_actorUserId_fkey" + FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql b/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql new file mode 100644 index 0000000..61ddf50 --- /dev/null +++ b/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED'; diff --git a/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql b/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql new file mode 100644 index 0000000..a10ed34 --- /dev/null +++ b/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "lab_cases" ADD COLUMN "externalCode" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 48d1409..66d1992 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -30,6 +30,8 @@ model User { labCaseComments LabCaseComment[] labCaseActivities LabCaseActivity[] phoneVerificationCodes PhoneVerificationCode[] + userNotifications UserNotification[] + actedUserNotifications UserNotification[] @relation("UserNotificationActor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -176,6 +178,8 @@ model TreatmentDetail { sortOrder Int treatmentType String teeth Json + /** Selection groups: connected spans + singles. Shape: { groupId, kind, teeth[] }[] */ + toothSelectionGroups Json? comment String? treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) @@ -217,6 +221,8 @@ model LabCase { sentAt DateTime? dueDate DateTime? isImportant Boolean @default(false) + /// Optional code from an external lab app (e.g. exocad) for print/matching. + externalCode String? accessToken String? @unique treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) @@ -340,6 +346,8 @@ model LabCaseToothProsthesis { treatmentDetailId String tooth String prosthesisTypeCode String + /** Links to TreatmentDetail.toothSelectionGroups[].groupId for task grouping. */ + selectionGroupId String @default("") labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) @@ -355,6 +363,8 @@ model LabCaseTask { teeth Json treatmentType String prosthesisTypeCode String + /** Empty string for legacy tasks; otherwise matches selection group id. */ + selectionGroupId String @default("") workflowStepCode String stepOrder Int stepLabel String @@ -373,7 +383,7 @@ model LabCaseTask { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) + @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") @@ -421,6 +431,7 @@ enum LabCaseActivityType { CASE_IMPORTANT CASE_AMENDED TASK_COMPLETED + TASK_ASSIGNED } enum LabCaseTabReadTarget { @@ -429,6 +440,38 @@ enum LabCaseTabReadTarget { TREATMENT } +enum UserNotificationType { + CASE_SENT + CLINIC_COMMENT + LAB_COMMENT + LAB_COMMENT_CLINIC + CASE_IMPORTANT + TASK_COMPLETED + TASK_ASSIGNED + CONNECTION_REQUEST + STAFF_INVITE +} + +/// Fan-out inbox row per recipient. Independent of LabCaseActivity tab badges. +model UserNotification { + id String @id @default(uuid()) + userId String + organizationId String + type UserNotificationType + actorUserId String? + payload Json? + href String + readAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + actorUser User? @relation("UserNotificationActor", fields: [actorUserId], references: [id], onDelete: SetNull) + + @@index([userId, organizationId, createdAt]) + @@index([userId, organizationId, readAt]) + @@map("user_notifications") +} + model LabCaseActivity { id String @id @default(uuid()) labCaseId String diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2df331d..2dfad1d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module'; import { TodayModule } from './modules/today/today.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { RealtimeModule } from './realtime/realtime.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul OrganizationModule, TodayModule, NotificationsModule, + RealtimeModule, AdminModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 51932fa..231de83 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -63,6 +63,8 @@ export const ErrorCode = { CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', + APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', + TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/common/lab-case-activity.ts b/backend/src/common/lab-case-activity.ts index c20723c..9b11fd0 100644 --- a/backend/src/common/lab-case-activity.ts +++ b/backend/src/common/lab-case-activity.ts @@ -7,10 +7,11 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ LabCaseActivityType.CASE_IMPORTANT, ]; -/** Lab Tasks tab — task completions and lab-side comments. */ +/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */ export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ LabCaseActivityType.TASK_COMPLETED, LabCaseActivityType.LAB_COMMENT, + LabCaseActivityType.TASK_ASSIGNED, ]; /** Clinic Treatment tab — visible lab comments and task progress. */ diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 7af2525..0a4755f 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; @@ -16,6 +17,7 @@ import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { hasEffectivePermission } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; const MS_PER_DAY = 86_400_000; @@ -215,9 +217,7 @@ export class AppointmentsService { select: { id: true }, }); if (linkedTreatment) { - throw new BadRequestException( - 'Cannot change the patient while a treatment is linked to this appointment', - ); + throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT); } } @@ -256,13 +256,17 @@ export class AppointmentsService { const existing = await this.prisma.appointment.findFirst({ where: { id, organizationId }, - select: { id: true }, + select: { id: true, treatment: { select: { id: true } } }, }); if (!existing) { throw new NotFoundException('Appointment not found'); } + if (existing.treatment) { + throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT); + } + await this.prisma.appointment.delete({ where: { id }, }); diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 451dbf5..3bcc7b8 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -82,6 +82,17 @@ export class CasesController { return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language); } + @Patch(':id/external-code') + @ApiOperation({ summary: 'Set or clear the optional external order code for a case' }) + setCaseExternalCode( + @Param('id') id: string, + @Body() dto: UpdateLabCaseExternalCodeDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.setCaseExternalCode(id, dto, organizationId, req.user.id, req.user.language); + } + @Patch(':caseId/tasks/:taskId/assign') @ApiOperation({ summary: 'Assign or unassign a task to lab staff' }) assignTask( diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 4f7b386..94976fc 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { createReadStream, existsSync } from 'fs'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -14,7 +14,7 @@ import { } from '../catalog/catalog-label.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; @@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { LabCaseAccessService } from './lab-case-access.service'; const labCaseListInclude = { @@ -96,6 +97,7 @@ export class CasesService { private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, private readonly labCaseAccess: LabCaseAccessService, ) {} @@ -129,7 +131,15 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, + tasks: { + select: { + id: true, + status: true, + prosthesisTypeCode: true, + teeth: true, + selectionGroupId: true, + }, + }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -242,7 +252,15 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, + tasks: { + select: { + id: true, + status: true, + prosthesisTypeCode: true, + teeth: true, + selectionGroupId: true, + }, + }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -385,6 +403,14 @@ export class CasesService { type: LabCaseActivityType.CASE_IMPORTANT, actorUserId, }); + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.CASE_IMPORTANT, + href: `/cases?caseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId }, + requiredPermission: 'TAB_CASES_READ', + }); } const labCase = await this.prisma.labCase.findFirstOrThrow({ @@ -395,6 +421,46 @@ export class CasesService { return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } + async setCaseExternalCode( + labCaseId: string, + dto: UpdateLabCaseExternalCodeDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException('Case not found'); + } + + const externalCode = + dto.externalCode == null || !String(dto.externalCode).trim() + ? null + : String(dto.externalCode).trim().slice(0, 64); + + await this.prisma.labCase.update({ + where: { id: labCaseId }, + data: { externalCode }, + }); + + const labCase = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + async listAssignableStaff(labOrganizationId: string, actorUserId: string) { await this.assertCanEditCases(actorUserId, labOrganizationId); @@ -476,6 +542,24 @@ export class CasesService { }, }); + if (assigneeUserId) { + await this.labCaseActivity.record({ + labCaseId, + type: LabCaseActivityType.TASK_ASSIGNED, + actorUserId, + payload: { taskId, assigneeUserId }, + }); + + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.TASK_ASSIGNED, + href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId, taskId, assigneeUserId }, + recipientUserIds: [assigneeUserId], + }); + } + const labCase = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, @@ -620,6 +704,7 @@ export class CasesService { dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, + externalCode: lc.externalCode ?? null, shareUrl, clinic: lc.treatment.organization, patient: lc.treatment.patient, @@ -637,6 +722,7 @@ export class CasesService { treatmentDetailId: row.treatmentDetailId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId: row.selectionGroupId?.trim() || undefined, })), attachments: lc.attachments.map((row) => ({ id: row.attachment.id, @@ -660,24 +746,44 @@ export class CasesService { } private buildProsthesisGroupsFromTasks( - tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>, - ): Array<{ prosthesisTypeCode: string; teeth: string[] }> { - const prosthesisByCode = new Map(); + tasks: Array<{ + prosthesisTypeCode: string; + teeth: Prisma.JsonValue; + selectionGroupId?: string; + }>, + ): Array<{ + prosthesisTypeCode: string; + teeth: string[]; + connected?: boolean; + selectionGroupId?: string; + }> { + const prosthesisByGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } + >(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; const teeth = normalizeTaskTeeth(task.teeth); - const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? []; - list.push(...teeth); - prosthesisByCode.set(task.prosthesisTypeCode, list); + const entry = prosthesisByGroup.get(key) ?? { + prosthesisTypeCode: task.prosthesisTypeCode, + teeth: [], + selectionGroupId, + }; + entry.teeth.push(...teeth); + prosthesisByGroup.set(key, entry); } - return [...prosthesisByCode.entries()] - .map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - teeth: [...new Set(teeth)].sort((a, b) => + return [...prosthesisByGroup.values()] + .map((group) => ({ + prosthesisTypeCode: group.prosthesisTypeCode, + teeth: [...new Set(group.teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }), ), + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); } @@ -694,12 +800,14 @@ export class CasesService { treatmentType: string; prosthesisTypeCode: string; prosthesisTypeLabel: string; + selectionGroupId: string; tasks: ReturnType[]; } >(); for (const task of tasks) { - const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { treatmentDetailId: task.treatmentDetailId, teeth: normalizeTaskTeeth(task.teeth), @@ -707,13 +815,23 @@ export class CasesService { prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + selectionGroupId, tasks: [], }; entry.tasks.push(this.mapTask(task, prosthesisLabels)); groups.set(key, entry); } - return [...groups.values()]; + return [...groups.values()].map((group) => ({ + treatmentDetailId: group.treatmentDetailId, + teeth: [...new Set(group.teeth)], + treatmentType: group.treatmentType, + prosthesisTypeCode: group.prosthesisTypeCode, + prosthesisTypeLabel: group.prosthesisTypeLabel, + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, + tasks: group.tasks, + })); } private mapTask( diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index 74ecc98..a553caf 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,11 +1,18 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class UpdateLabCaseImportantDto { @IsBoolean() isImportant: boolean; } +export class UpdateLabCaseExternalCodeDto { + @IsOptional() + @IsString() + @MaxLength(64) + externalCode?: string | null; +} + export class AssignLabCaseTaskDto { @IsOptional() @IsUUID() diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 6ab178d..50c18d1 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -293,24 +293,41 @@ export class LabCaseAccessService { ) { const tasks = await this.prisma.labCaseTask.findMany({ where: { labCaseId }, - select: { prosthesisTypeCode: true, teeth: true }, + select: { prosthesisTypeCode: true, teeth: true, selectionGroupId: true }, orderBy: [{ prosthesisTypeCode: 'asc' }], }); - const byCode = new Map(); + const byGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } + >(); for (const task of tasks) { if (!task.prosthesisTypeCode) continue; + const selectionGroupId = task.selectionGroupId ?? ''; + const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; const teeth = normalizeTaskTeeth(task.teeth); - const list = byCode.get(task.prosthesisTypeCode) ?? []; - list.push(...teeth); - byCode.set(task.prosthesisTypeCode, list); + const entry = byGroup.get(key) ?? { + prosthesisTypeCode: task.prosthesisTypeCode, + teeth: [], + selectionGroupId, + }; + entry.teeth.push(...teeth); + byGroup.set(key, entry); } - return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode, - teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), - })); + return [...byGroup.values()].map((group) => { + const teeth = [...new Set(group.teeth)].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ); + return { + prosthesisTypeCode: group.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode, + teeth, + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && teeth.length > 1, + }; + }); } private async getMembership(userId: string, organizationId: string) { diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts index 31abb0e..935dbcf 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -11,6 +11,7 @@ function buildMockTx(options: { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; + selectionGroupId?: string; treatmentType?: string; }>; prosthesisTypes: Array<{ @@ -35,9 +36,11 @@ function buildMockTx(options: { treatmentDetailId: row.treatmentDetailId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId: row.selectionGroupId ?? '', detail: { id: row.treatmentDetailId, treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, }, })), ), @@ -169,6 +172,41 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('printer_resin'); }); + it('merges teeth with the same prosthesis type across selection groups', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '14', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '15', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '21', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'single-21', + }, + ], + prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en'); + expect(count).toBe(pfmSteps.length); + const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; + expect(rows).toHaveLength(pfmSteps.length); + expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe( + true, + ); + }); + it('skips generation when tasks already exist', async () => { const { tx } = buildMockTx({ existingCount: 3, diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 3279291..063056f 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -18,7 +18,7 @@ export async function generateLabCaseTasks( const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({ where: { labCaseId }, include: { - detail: { select: { id: true, treatmentType: true } }, + detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, }, }); @@ -58,21 +58,34 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); - // Group teeth that share the same (treatment detail + prosthesis type): one task set - // per group, with each step covering every tooth in that group. + // Merge all teeth that share the same prosthesis type on a detail into one + // task set (pre–selection-group behavior). selectionGroupId is stored for + // UI/history only — not part of the grouping key. const groups = new Map< string, - { treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] } + { + treatmentDetailId: string; + treatmentType: string; + prosthesisTypeCode: string; + selectionGroupId: string; + teeth: string[]; + } >(); for (const row of toothProsthesisRows) { const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const selectionGroupId = row.selectionGroupId?.trim() || ''; const group = groups.get(key) ?? { treatmentDetailId: row.treatmentDetailId, treatmentType: row.detail.treatmentType, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId, teeth: [], }; + // Keep first non-empty selectionGroupId for persistence; grouping ignores it. + if (!group.selectionGroupId && selectionGroupId) { + group.selectionGroupId = selectionGroupId; + } group.teeth.push(row.tooth); groups.set(key, group); } @@ -94,6 +107,7 @@ export async function generateLabCaseTasks( teeth, treatmentType: group.treatmentType, prosthesisTypeCode: group.prosthesisTypeCode, + selectionGroupId: group.selectionGroupId, workflowStepCode: step.workflowStepCode, stepOrder: step.stepOrder, stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index 60e5a9d..794f8e5 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -3,12 +3,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client'; +import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; const commentInclude = { authorUser: { select: { id: true, name: true } }, @@ -24,6 +25,7 @@ export class LabCaseCommentsService { constructor( private readonly prisma: PrismaService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} // ---------- Lab side (TAB_TASKS_EDIT) ---------- @@ -67,6 +69,31 @@ export class LabCaseCommentsService { visibleToClinic: created.visibleToClinic, }, }); + + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.LAB_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_TASKS_READ', + }); + + if (created.visibleToClinic) { + const clinicOrgId = await this.clinicOrgIdForCase(caseId); + if (clinicOrgId) { + void this.userNotifications.notify({ + organizationId: clinicOrgId, + type: UserNotificationType.LAB_COMMENT_CLINIC, + href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_TREATMENT_READ', + labCaseIdForProviderScope: caseId, + }); + } + } + return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) }; } @@ -130,6 +157,17 @@ export class LabCaseCommentsService { actorUserId, payload: { commentId: created.id }, }); + const labOrgId = await this.labOrgIdForCase(caseId); + if (labOrgId) { + void this.userNotifications.notify({ + organizationId: labOrgId, + type: UserNotificationType.CLINIC_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_CASES_READ', + }); + } return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } @@ -172,6 +210,17 @@ export class LabCaseCommentsService { actorUserId, payload: { commentId: created.id }, }); + const labOrgId = await this.labOrgIdForCase(caseId); + if (labOrgId) { + void this.userNotifications.notify({ + organizationId: labOrgId, + type: UserNotificationType.CLINIC_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_CASES_READ', + }); + } return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } @@ -336,4 +385,21 @@ export class LabCaseCommentsService { } throw new ForbiddenException('You do not have access to treatment cases'); } + + private async labOrgIdForCase(caseId: string): Promise { + const send = await this.prisma.labCaseSend.findFirst({ + where: { labCaseId: caseId }, + orderBy: { sentAt: 'asc' }, + select: { organizationId: true }, + }); + return send?.organizationId ?? null; + } + + private async clinicOrgIdForCase(caseId: string): Promise { + const labCase = await this.prisma.labCase.findUnique({ + where: { id: caseId }, + select: { treatment: { select: { organizationId: true } } }, + }); + return labCase?.treatment.organizationId ?? null; + } } diff --git a/backend/src/modules/notifications/dto/notifications.dto.ts b/backend/src/modules/notifications/dto/notifications.dto.ts index 90f8667..98cf103 100644 --- a/backend/src/modules/notifications/dto/notifications.dto.ts +++ b/backend/src/modules/notifications/dto/notifications.dto.ts @@ -1,4 +1,5 @@ -import { IsEnum, IsUUID } from 'class-validator'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; import { LabCaseTabReadTarget } from '@prisma/client'; export class MarkTabReadDto { @@ -10,3 +11,16 @@ export class MarkCaseReadDto { @IsUUID() labCaseId!: string; } + +export class ListInboxQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number; + + @IsOptional() + @IsString() + cursor?: string; +} diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index bcf3e06..d19bd94 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -318,7 +318,10 @@ export class LabCaseActivityService { const commentId = payload?.commentId; if (typeof commentId === 'string') commentIds.push(commentId); } - if (activity.type === LabCaseActivityType.TASK_COMPLETED) { + if ( + activity.type === LabCaseActivityType.TASK_COMPLETED || + activity.type === LabCaseActivityType.TASK_ASSIGNED + ) { const taskId = payload?.taskId; if (typeof taskId === 'string') taskIds.push(taskId); } @@ -387,18 +390,44 @@ export class LabCaseActivityService { }; const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType); + const includesTaskAssigned = types.includes(LabCaseActivityType.TASK_ASSIGNED); + const otherTypes = types.filter((type) => type !== LabCaseActivityType.TASK_ASSIGNED); + + // TASK_ASSIGNED: count for the assignee only (including self-assign). + // Other task-tab types: org-wide, excluding events the current user authored. + const typeFilter: Prisma.LabCaseActivityWhereInput = includesTaskAssigned + ? { + OR: [ + ...(otherTypes.length > 0 + ? [ + { + AND: [ + { type: { in: otherTypes } }, + { + OR: [{ actorUserId: null }, { actorUserId: { not: userId } }], + }, + ], + }, + ] + : []), + { + type: LabCaseActivityType.TASK_ASSIGNED, + payload: { path: ['assigneeUserId'], equals: userId }, + }, + ], + } + : { + AND: [ + { type: { in: types } }, + { OR: [{ actorUserId: null }, { actorUserId: { not: userId } }] }, + ], + }; return this.prisma.labCaseActivity.count({ where: { - type: { in: types }, createdAt: { gt: since }, labCase: labCaseScope, - AND: [ - { - OR: [{ actorUserId: null }, { actorUserId: { not: userId } }], - }, - clinicLabCommentFilter, - ], + AND: [clinicLabCommentFilter, typeFilter], }, }); } diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts index d040e8f..d175b21 100644 --- a/backend/src/modules/notifications/notifications.controller.ts +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -1,15 +1,34 @@ -import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto'; +import { + ListInboxQueryDto, + MarkCaseReadDto, + MarkTabReadDto, +} from './dto/notifications.dto'; import { LabCaseActivityService } from './lab-case-activity.service'; +import { UserNotificationService } from './user-notification.service'; @ApiTags('notifications') @ApiBearerAuth('JWT-auth') @UseGuards(JwtAuthGuard) @Controller('notifications') export class NotificationsController { - constructor(private readonly labCaseActivityService: LabCaseActivityService) {} + constructor( + private readonly labCaseActivityService: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, + ) {} @Get('tab-counts') @ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' }) @@ -69,4 +88,53 @@ export class NotificationsController { dto.labCaseId, ); } + + @Get('inbox') + @ApiOperation({ summary: 'Paginated notification inbox for the current user and org' }) + listInbox( + @Query() query: ListInboxQueryDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: { items: [], nextCursor: null } }; + } + return this.userNotifications.listInbox(req.user.id, organizationId, { + limit: query.limit, + cursor: query.cursor, + }); + } + + @Get('inbox/unread-count') + @ApiOperation({ summary: 'Unread inbox count for the header bell' }) + inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: { count: 0 } }; + } + return this.userNotifications.unreadCount(req.user.id, organizationId); + } + + @Post('inbox/read-all') + @ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' }) + markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true }; + } + return this.userNotifications.markAllRead(req.user.id, organizationId); + } + + @Post('inbox/:id/read') + @ApiOperation({ summary: 'Mark a single inbox notification as read' }) + markInboxRead( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: null }; + } + return this.userNotifications.markRead(req.user.id, organizationId, id); + } } diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts index 805af2e..3314a38 100644 --- a/backend/src/modules/notifications/notifications.module.ts +++ b/backend/src/modules/notifications/notifications.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { LabCaseActivityService } from './lab-case-activity.service'; import { NotificationsController } from './notifications.controller'; +import { UserNotificationService } from './user-notification.service'; +import { RealtimeModule } from '../../realtime/realtime.module'; @Module({ + imports: [RealtimeModule], controllers: [NotificationsController], - providers: [LabCaseActivityService], - exports: [LabCaseActivityService], + providers: [LabCaseActivityService, UserNotificationService], + exports: [LabCaseActivityService, UserNotificationService], }) export class NotificationsModule {} diff --git a/backend/src/modules/notifications/user-notification.service.ts b/backend/src/modules/notifications/user-notification.service.ts new file mode 100644 index 0000000..a816325 --- /dev/null +++ b/backend/src/modules/notifications/user-notification.service.ts @@ -0,0 +1,350 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, UserNotificationType } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { hasEffectivePermission } from '../../common/membership-permissions'; +import { isActorTreatmentProvider } from '../../common/treatment-provider-scope'; +import { RealtimeEmitter } from '../../realtime/realtime.emitter'; + +const membershipInclude = { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, +} satisfies Prisma.MembershipInclude; + +export type UserNotificationDto = { + id: string; + type: UserNotificationType; + href: string; + payload: Record | null; + readAt: string | null; + createdAt: string; + actorUserId: string | null; +}; + +type FanoutInput = { + organizationId: string; + type: UserNotificationType; + href: string; + actorUserId?: string | null; + payload?: Record | null; + /** Explicit recipient user ids (e.g. task assignee). Skips permission fan-out. */ + recipientUserIds?: string[]; + /** Required tab permission when resolving org members. */ + requiredPermission?: string; + /** When set, only clinic members who are the treatment provider for this case. */ + labCaseIdForProviderScope?: string; +}; + +@Injectable() +export class UserNotificationService { + constructor( + private readonly prisma: PrismaService, + private readonly realtime: RealtimeEmitter, + ) {} + + /** + * Lean lab-case snapshot for inbox cards. Used only inside `notify()` so + * inbox list/read never joins patients/orgs/tasks. + */ + private async labCaseInboxPayload( + labCaseId: string, + extra?: Record, + ): Promise> { + const labCase = await this.prisma.labCase.findUnique({ + where: { id: labCaseId }, + select: { + id: true, + destinationOrganizationId: true, + treatment: { + select: { + organization: { select: { name: true } }, + patient: { select: { firstName: true, lastName: true } }, + }, + }, + toothProsthesis: { select: { prosthesisTypeCode: true } }, + tasks: { select: { prosthesisTypeCode: true }, take: 40 }, + sends: { + orderBy: { sentAt: 'asc' }, + take: 1, + select: { organization: { select: { name: true } } }, + }, + }, + }); + + if (!labCase?.treatment) { + return { labCaseId, ...(extra ?? {}) }; + } + + let labName: string | null = labCase.sends[0]?.organization.name ?? null; + if (!labName && labCase.destinationOrganizationId) { + const dest = await this.prisma.organization.findUnique({ + where: { id: labCase.destinationOrganizationId }, + select: { name: true }, + }); + labName = dest?.name ?? null; + } + + const prosthesisTypeCodes = [ + ...new Set( + [ + ...labCase.toothProsthesis.map((row) => row.prosthesisTypeCode), + ...labCase.tasks.map((row) => row.prosthesisTypeCode), + ].filter(Boolean), + ), + ]; + + const patient = labCase.treatment.patient; + const patientName = `${patient.firstName} ${patient.lastName}`.trim(); + + // Caller ids (taskId, commentId, …) come from `extra`; denormalized display + // fields must win so they are never overwritten by a thin emit payload. + return { + ...(extra ?? {}), + labCaseId, + patientName, + clinicName: labCase.treatment.organization.name, + labName, + prosthesisTypeCodes, + }; + } + + /** Denormalize display fields into payload once at write time. */ + private async enrichInboxPayload( + input: FanoutInput, + ): Promise | null> { + const base = + input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload) + ? { ...(input.payload as Record) } + : {}; + + try { + const labCaseId = + (typeof base.labCaseId === 'string' && base.labCaseId) || + input.labCaseIdForProviderScope || + null; + + let enriched: Record = { ...base }; + + if (labCaseId) { + enriched = await this.labCaseInboxPayload(labCaseId, enriched); + } + + const taskId = typeof enriched.taskId === 'string' ? enriched.taskId : null; + if (taskId && typeof enriched.taskName !== 'string') { + const task = await this.prisma.labCaseTask.findUnique({ + where: { id: taskId }, + select: { stepLabel: true, prosthesisTypeCode: true }, + }); + if (task) { + enriched = { + ...enriched, + taskName: task.stepLabel, + prosthesisTypeCode: task.prosthesisTypeCode, + }; + } + } + + const fromOrganizationId = + typeof enriched.fromOrganizationId === 'string' ? enriched.fromOrganizationId : null; + if (fromOrganizationId && typeof enriched.fromOrganizationName !== 'string') { + const org = await this.prisma.organization.findUnique({ + where: { id: fromOrganizationId }, + select: { name: true }, + }); + if (org) { + enriched = { ...enriched, fromOrganizationName: org.name }; + } + } + + const membershipId = + typeof enriched.membershipId === 'string' ? enriched.membershipId : null; + if (membershipId && typeof enriched.inviteeName !== 'string') { + const membership = await this.prisma.membership.findUnique({ + where: { id: membershipId }, + select: { user: { select: { name: true, email: true } } }, + }); + if (membership?.user) { + enriched = { + ...enriched, + inviteeName: membership.user.name, + email: + typeof enriched.email === 'string' && enriched.email + ? enriched.email + : membership.user.email, + }; + } + } + + return Object.keys(enriched).length > 0 ? enriched : null; + } catch { + // Never block inbox write if enrichment fails — store the thin payload. + return Object.keys(base).length > 0 ? base : null; + } + } + + async notify(input: FanoutInput): Promise { + // Explicit recipients keep the actor (e.g. self-assign). Fan-out still skips the actor. + const recipientIds = input.recipientUserIds?.length + ? [...new Set(input.recipientUserIds.filter((id): id is string => Boolean(id)))] + : await this.resolveRecipients(input); + + if (recipientIds.length === 0) return; + + const payload = await this.enrichInboxPayload(input); + + const rows = await this.prisma.userNotification.createManyAndReturn({ + data: recipientIds.map((userId) => ({ + userId, + organizationId: input.organizationId, + type: input.type, + actorUserId: input.actorUserId ?? null, + payload: (payload ?? Prisma.JsonNull) as Prisma.InputJsonValue, + href: input.href, + })), + }); + + for (const row of rows) { + const dto = this.mapRow(row); + this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.created', { + notification: dto, + }); + const unreadCount = await this.countUnread(row.userId, row.organizationId); + this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.unreadCount', { + count: unreadCount, + }); + } + } + + async listInbox( + userId: string, + organizationId: string, + options?: { limit?: number; cursor?: string }, + ) { + const limit = Math.min(Math.max(options?.limit ?? 20, 1), 50); + const rows = await this.prisma.userNotification.findMany({ + where: { userId, organizationId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + ...(options?.cursor + ? { + cursor: { id: options.cursor }, + skip: 1, + } + : {}), + }); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null; + + return { + success: true as const, + data: { + items: page.map((row) => this.mapRow(row)), + nextCursor, + }, + }; + } + + async unreadCount(userId: string, organizationId: string) { + const count = await this.countUnread(userId, organizationId); + return { success: true as const, data: { count } }; + } + + async markRead(userId: string, organizationId: string, notificationId: string) { + const existing = await this.prisma.userNotification.findFirst({ + where: { id: notificationId, userId, organizationId }, + }); + if (!existing) { + return { success: true as const, data: null }; + } + if (!existing.readAt) { + const updated = await this.prisma.userNotification.update({ + where: { id: notificationId }, + data: { readAt: new Date() }, + }); + const count = await this.countUnread(userId, organizationId); + this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', { + count, + }); + return { success: true as const, data: this.mapRow(updated) }; + } + return { success: true as const, data: this.mapRow(existing) }; + } + + async markAllRead(userId: string, organizationId: string) { + await this.prisma.userNotification.updateMany({ + where: { userId, organizationId, readAt: null }, + data: { readAt: new Date() }, + }); + this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', { + count: 0, + }); + return { success: true as const }; + } + + private async countUnread(userId: string, organizationId: string): Promise { + return this.prisma.userNotification.count({ + where: { userId, organizationId, readAt: null }, + }); + } + + private mapRow(row: { + id: string; + type: UserNotificationType; + href: string; + payload: Prisma.JsonValue; + readAt: Date | null; + createdAt: Date; + actorUserId: string | null; + }): UserNotificationDto { + return { + id: row.id, + type: row.type, + href: row.href, + payload: + row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload) + ? (row.payload as Record) + : null, + readAt: row.readAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + actorUserId: row.actorUserId, + }; + } + + private async resolveRecipients(input: FanoutInput): Promise { + if (!input.requiredPermission) return []; + + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId: input.organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: membershipInclude, + }); + + let userIds = memberships + .filter((m) => hasEffectivePermission(m, input.requiredPermission!)) + .map((m) => m.userId) + .filter((id) => id !== input.actorUserId); + + if (input.labCaseIdForProviderScope) { + const labCase = await this.prisma.labCase.findUnique({ + where: { id: input.labCaseIdForProviderScope }, + select: { + treatment: { + select: { + providerUserId: true, + appointment: { select: { providerUserId: true } }, + }, + }, + }, + }); + if (!labCase?.treatment) return []; + userIds = userIds.filter((userId) => + isActorTreatmentProvider(labCase.treatment, userId), + ); + } + + return [...new Set(userIds)]; + } +} diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index 18a7d93..723212e 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { CasesModule } from '../cases/cases.module'; import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { OrganizationController } from './organization.controller'; import { OrganizationService } from './organization.service'; @Module({ - imports: [CasesModule, LabCaseCommentsModule], + imports: [CasesModule, LabCaseCommentsModule, NotificationsModule], controllers: [OrganizationController], providers: [OrganizationService, PrismaService], }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 65b2184..c4dcd59 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -5,7 +5,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LinkStatus } from '@prisma/client'; +import { LinkStatus, UserNotificationType } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto'; import { CasesService } from '../cases/cases.service'; import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; @@ -36,6 +37,7 @@ export class OrganizationService { private readonly prisma: PrismaService, private readonly casesService: CasesService, private readonly commentsService: LabCaseCommentsService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -269,6 +271,15 @@ export class OrganizationService { }, }); + void this.userNotifications.notify({ + organizationId: dto.targetOrganizationId, + type: UserNotificationType.CONNECTION_REQUEST, + href: '/organizations', + actorUserId: userId, + payload: { organizationLinkId: created.id, fromOrganizationId: organizationId }, + requiredPermission: 'TAB_ORGANIZATIONS_READ', + }); + return { success: true, data: { id: created.id, status: created.status }, diff --git a/backend/src/modules/staff/staff.module.ts b/backend/src/modules/staff/staff.module.ts index 0fefc9c..9258e96 100644 --- a/backend/src/modules/staff/staff.module.ts +++ b/backend/src/modules/staff/staff.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { NotificationsModule } from '../notifications/notifications.module'; import { StaffController } from './staff.controller'; import { StaffService } from './staff.service'; import { StaffWorkingHoursService } from './staff-working-hours.service'; @Module({ + imports: [NotificationsModule], controllers: [StaffController], providers: [StaffService, StaffWorkingHoursService, PrismaService], exports: [StaffWorkingHoursService], diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index eb476b8..f7bb2e4 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; -import { Prisma } from '@prisma/client'; +import { Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; @@ -17,10 +17,14 @@ import { } from '../../common/organization-type'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { UserNotificationService } from '../notifications/user-notification.service'; @Injectable() export class StaffService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly userNotifications: UserNotificationService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -217,6 +221,19 @@ export class StaffService { }; }); + void this.userNotifications.notify({ + organizationId, + type: UserNotificationType.STAFF_INVITE, + href: '/staff', + actorUserId: userId, + payload: { + membershipId: result.membershipId, + staffInvitationId: result.invitationId, + email, + }, + requiredPermission: 'TAB_STAFF_READ', + }); + return { success: true, data: { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 67d80a7..9a84300 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date' import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; const taskListInclude = { lastStatusChangedBy: { select: { id: true, name: true } }, @@ -33,12 +34,16 @@ const taskListInclude = { }, } 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() export class TasksService { constructor( private readonly prisma: PrismaService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -238,12 +243,13 @@ export class TasksService { } const updated = await this.prisma.$transaction(async (tx) => { + const now = new Date(); const result = await tx.labCaseTask.update({ where: { id: taskId }, data: { status: dto.status, lastStatusChangedByUserId: actorUserId, - lastStatusChangedAt: new Date(), + lastStatusChangedAt: now, }, include: taskListInclude, }); @@ -259,13 +265,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) { await this.labCaseActivity.record( { labCaseId: task.labCaseId, type: LabCaseActivityType.TASK_COMPLETED, actorUserId, - payload: { taskId }, + payload: { + taskId, + ...(cascadedScanTaskIds.length > 0 + ? { + cascadedTaskIds: cascadedScanTaskIds, + caseScopedStep: CASE_SCOPED_SCAN_STEP_CODE, + } + : {}), + }, }, tx, ); @@ -274,6 +325,30 @@ export class TasksService { return result; }); + if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) { + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.TASK_COMPLETED, + href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`, + actorUserId, + payload: { labCaseId: task.labCaseId, taskId }, + requiredPermission: 'TAB_TASKS_READ', + }); + + const clinicOrgId = task.labCase?.treatment?.organization?.id; + if (clinicOrgId) { + void this.userNotifications.notify({ + organizationId: clinicOrgId, + type: UserNotificationType.TASK_COMPLETED, + href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`, + actorUserId, + payload: { labCaseId: task.labCaseId, taskId }, + requiredPermission: 'TAB_TREATMENT_READ', + labCaseIdForProviderScope: task.labCaseId, + }); + } + } + const locale = normalizeCatalogLocale(localeInput); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, diff --git a/backend/src/modules/today/dto/today-summary-query.dto.ts b/backend/src/modules/today/dto/today-summary-query.dto.ts index 9beef55..1c7cb1a 100644 --- a/backend/src/modules/today/dto/today-summary-query.dto.ts +++ b/backend/src/modules/today/dto/today-summary-query.dto.ts @@ -1,7 +1,10 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsIn, IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; import { Type } from 'class-transformer'; +const WEEK_MONTH = ['week', 'month'] as const; +const WEEK_MONTH_YEAR = ['week', 'month', 'year'] as const; + export class TodaySummaryQueryDto { @ApiPropertyOptional({ description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.', @@ -30,4 +33,39 @@ export class TodaySummaryQueryDto { @Min(-840) @Max(840) utcOffsetMinutes?: number; + + @ApiPropertyOptional({ enum: WEEK_MONTH }) + @IsOptional() + @IsIn(WEEK_MONTH) + appointmentsWeekPeriod?: 'week' | 'month'; + + @ApiPropertyOptional({ enum: WEEK_MONTH }) + @IsOptional() + @IsIn(WEEK_MONTH) + appointmentsWeekMinePeriod?: 'week' | 'month'; + + @ApiPropertyOptional({ enum: WEEK_MONTH }) + @IsOptional() + @IsIn(WEEK_MONTH) + labTaskActivityPeriod?: 'week' | 'month'; + + @ApiPropertyOptional({ enum: WEEK_MONTH }) + @IsOptional() + @IsIn(WEEK_MONTH) + casesDuePeriod?: 'week' | 'month'; + + @ApiPropertyOptional({ enum: WEEK_MONTH_YEAR }) + @IsOptional() + @IsIn(WEEK_MONTH_YEAR) + treatmentMixPeriod?: 'week' | 'month' | 'year'; + + @ApiPropertyOptional({ enum: WEEK_MONTH_YEAR }) + @IsOptional() + @IsIn(WEEK_MONTH_YEAR) + casePartnersPeriod?: 'week' | 'month' | 'year'; + + @ApiPropertyOptional({ enum: WEEK_MONTH_YEAR }) + @IsOptional() + @IsIn(WEEK_MONTH_YEAR) + efficiencyPeriod?: 'week' | 'month' | 'year'; } diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 6ec22e4..1410061 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -145,6 +145,7 @@ export class TodayService { organizationId, to, query.utcOffsetMinutes, + resolveWeekMonthPeriod(query.appointmentsWeekPeriod, 'week'), charts, ), ); @@ -158,6 +159,7 @@ export class TodayService { userId, !membership.isOwner, to, + resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'), charts, ), ); @@ -177,7 +179,13 @@ export class TodayService { ); tasks.push(this.loadLabCasesPendingSend(organizationId, widgets)); tasks.push( - this.loadTreatmentMixWeek(organizationId, to, locale, charts), + this.loadTreatmentMixWeek( + organizationId, + to, + locale, + resolveWeekMonthYearPeriod(query.treatmentMixPeriod, 'week'), + charts, + ), ); } @@ -197,6 +205,7 @@ export class TodayService { userId, to, query.utcOffsetMinutes, + resolveWeekMonthPeriod(query.appointmentsWeekMinePeriod, 'week'), charts, ), ); @@ -234,6 +243,7 @@ export class TodayService { organizationId, from, query.utcOffsetMinutes, + resolveWeekMonthPeriod(query.casesDuePeriod, 'week'), charts, ), ); @@ -247,6 +257,7 @@ export class TodayService { userId, false, to, + resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'), charts, ), ); @@ -266,6 +277,7 @@ export class TodayService { organizationId, to, query.utcOffsetMinutes, + resolveWeekMonthPeriod(query.labTaskActivityPeriod, 'week'), charts, ), ); @@ -279,11 +291,19 @@ export class TodayService { } if (membership.isOwner) { + const efficiencyPeriod = resolveWeekMonthYearPeriod( + query.efficiencyPeriod, + 'month', + ); if (orgType === 'CLINIC') { - tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts)); + tasks.push( + this.loadClinicEfficiencyReport(organizationId, to, efficiencyPeriod, charts), + ); } if (orgType === 'LAB') { - tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts)); + tasks.push( + this.loadLabEfficiencyReport(organizationId, to, efficiencyPeriod, charts), + ); } tasks.push( this.buildSubscriptionWidget(organizationId, membership.organization).then( @@ -318,15 +338,18 @@ export class TodayService { organizationId: string, rangeEnd: Date, locale: CatalogLocale, + period: ChartPeriodWeekMonthYear, charts: TodayCharts, ) { - const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000); + const windowStart = new Date( + rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000, + ); const grouped = await this.prisma.treatmentDetail.groupBy({ by: ['treatmentType'], where: { treatment: { organizationId, - treatmentAt: { gte: weekStart, lt: rangeEnd }, + treatmentAt: { gte: windowStart, lt: rangeEnd }, }, }, _count: { _all: true }, @@ -647,6 +670,7 @@ export class TodayService { private async loadClinicEfficiencyReport( organizationId: string, rangeEnd: Date, + period: ChartPeriodWeekMonthYear, charts: TodayCharts, ) { const members = await this.getActiveEditAccessMembers( @@ -654,12 +678,14 @@ export class TodayService { 'TAB_TREATMENT_EDIT', ); - const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); + const windowStart = new Date( + rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000, + ); const grouped = await this.prisma.treatment.groupBy({ by: ['providerUserId'], where: { organizationId, - treatmentAt: { gte: monthStart, lt: rangeEnd }, + treatmentAt: { gte: windowStart, lt: rangeEnd }, providerUserId: { in: members.map((member) => member.userId) }, }, _count: { _all: true }, @@ -681,6 +707,7 @@ export class TodayService { private async loadLabEfficiencyReport( labOrganizationId: string, rangeEnd: Date, + period: ChartPeriodWeekMonthYear, charts: TodayCharts, ) { const members = await this.getActiveEditAccessMembers( @@ -688,12 +715,14 @@ export class TodayService { 'TAB_TASKS_EDIT', ); - const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); + const windowStart = new Date( + rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000, + ); const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({ by: ['changedByUserId'], where: { toStatus: LabTaskStatus.COMPLETED, - changedAt: { gte: monthStart, lt: rangeEnd }, + changedAt: { gte: windowStart, lt: rangeEnd }, changedByUserId: { in: members.map((member) => member.userId) }, task: { labCase: { @@ -926,9 +955,15 @@ export class TodayService { labOrganizationId: string, rangeStart: Date, utcOffsetMinutes: number | undefined, + period: ChartPeriodWeekMonth, charts: TodayCharts, ) { - const dayBuckets = buildNextSevenLocalDayBuckets(rangeStart, utcOffsetMinutes); + const dayBuckets = buildLocalDayBuckets( + rangeStart, + daysForChartPeriod(period), + 'forward', + utcOffsetMinutes, + ); const weekStart = dayBuckets[0]?.start ?? rangeStart; const weekEnd = dayBuckets[dayBuckets.length - 1]?.end ?? rangeStart; const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; @@ -1034,12 +1069,14 @@ export class TodayService { organizationId: string, rangeEnd: Date, utcOffsetMinutes: number | undefined, + period: ChartPeriodWeekMonth, charts: TodayCharts, ) { charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries( organizationId, rangeEnd, utcOffsetMinutes, + period, ); } @@ -1048,12 +1085,14 @@ export class TodayService { providerUserId: string, rangeEnd: Date, utcOffsetMinutes: number | undefined, + period: ChartPeriodWeekMonth, charts: TodayCharts, ) { charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries( organizationId, rangeEnd, utcOffsetMinutes, + period, providerUserId, ); } @@ -1062,9 +1101,15 @@ export class TodayService { organizationId: string, rangeEnd: Date, utcOffsetMinutes: number | undefined, + period: ChartPeriodWeekMonth, providerUserId?: string, ): Promise { - const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes); + const dayBuckets = buildLocalDayBuckets( + rangeEnd, + daysForChartPeriod(period), + 'lookback', + utcOffsetMinutes, + ); const weekStart = dayBuckets[0]?.start ?? rangeEnd; const weekEnd = rangeEnd; @@ -1101,9 +1146,15 @@ export class TodayService { labOrganizationId: string, rangeEnd: Date, utcOffsetMinutes: number | undefined, + period: ChartPeriodWeekMonth, charts: TodayCharts, ) { - const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes); + const dayBuckets = buildLocalDayBuckets( + rangeEnd, + daysForChartPeriod(period), + 'lookback', + utcOffsetMinutes, + ); const weekStart = dayBuckets[0]?.start ?? rangeEnd; const weekEnd = rangeEnd; const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; @@ -1168,9 +1219,12 @@ export class TodayService { userId: string, scopeToUser: boolean, rangeEnd: Date, + period: ChartPeriodWeekMonthYear, charts: TodayCharts, ) { - const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); + const rangeStart = new Date( + rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000, + ); const cases = await this.prisma.labCase.findMany({ where: { @@ -1363,51 +1417,64 @@ function aggregateCount( type LocalDayBucket = { code: string; label: string; start: Date; end: Date }; -function buildLastSevenLocalDayBuckets( - rangeEnd: Date, +type ChartPeriodWeekMonth = 'week' | 'month'; +type ChartPeriodWeekMonthYear = 'week' | 'month' | 'year'; + +function daysForChartPeriod(period: ChartPeriodWeekMonthYear): 7 | 30 | 365 { + switch (period) { + case 'month': + return 30; + case 'year': + return 365; + default: + return 7; + } +} + +function resolveWeekMonthPeriod( + value: string | undefined, + fallback: ChartPeriodWeekMonth, +): ChartPeriodWeekMonth { + return value === 'week' || value === 'month' ? value : fallback; +} + +function resolveWeekMonthYearPeriod( + value: string | undefined, + fallback: ChartPeriodWeekMonthYear, +): ChartPeriodWeekMonthYear { + return value === 'week' || value === 'month' || value === 'year' ? value : fallback; +} + +function buildLocalDayBuckets( + anchor: Date, + count: number, + direction: 'lookback' | 'forward', utcOffsetMinutes?: number, ): LocalDayBucket[] { const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; const dayMs = 86_400_000; const buckets: LocalDayBucket[] = []; - for (let index = 0; index < 7; index += 1) { - const start = new Date(rangeEnd.getTime() - (7 - index) * dayMs); - const end = new Date(start.getTime() + dayMs); - const code = localDayKeyFromDate(start, offsetMs); - buckets.push({ - code, - label: code, - start, - end, - }); + if (direction === 'lookback') { + for (let index = 0; index < count; index += 1) { + const start = new Date(anchor.getTime() - (count - index) * dayMs); + const end = new Date(start.getTime() + dayMs); + const code = localDayKeyFromDate(start, offsetMs); + buckets.push({ code, label: code, start, end }); + } + return buckets; } - return buckets; -} - -function buildNextSevenLocalDayBuckets( - rangeStart: Date, - utcOffsetMinutes?: number, -): LocalDayBucket[] { - const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; - const dayMs = 86_400_000; - const localMs = rangeStart.getTime() + offsetMs; + const localMs = anchor.getTime() + offsetMs; const local = new Date(localMs); local.setUTCHours(0, 0, 0, 0); const dayStart = new Date(local.getTime() - offsetMs); - const buckets: LocalDayBucket[] = []; - for (let index = 0; index < 7; index += 1) { + for (let index = 0; index < count; index += 1) { const start = new Date(dayStart.getTime() + index * dayMs); const end = new Date(start.getTime() + dayMs); const code = localDayKeyFromDate(start, offsetMs); - buckets.push({ - code, - label: code, - start, - end, - }); + buckets.push({ code, label: code, start, end }); } return buckets; diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index f8a0e97..3bb6a24 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -2,6 +2,7 @@ import { ArrayMinSize, IsArray, IsDateString, + IsIn, IsOptional, IsString, IsUUID, @@ -10,6 +11,19 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; +export class ToothSelectionGroupDto { + @IsString() + @MaxLength(64) + groupId: string; + + @IsIn(['connected', 'single']) + kind: 'connected' | 'single'; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; +} + export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) @@ -27,6 +41,12 @@ export class SaveTreatmentDetailDto { @IsString({ each: true }) teeth: string[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ToothSelectionGroupDto) + toothSelectionGroups?: ToothSelectionGroupDto[]; + @IsOptional() @IsString() @MaxLength(5000) @@ -57,6 +77,11 @@ export class LabCaseToothProsthesisDto { @IsString() @MaxLength(64) prosthesisTypeCode: string; + + @IsOptional() + @IsString() + @MaxLength(64) + selectionGroupId?: string; } export class SaveLabCaseDto { diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index f2bfdfa..9a0e4b3 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -20,6 +20,42 @@ export function normalizeTeeth(teeth: unknown): string[] { return [...unique].sort(); } +export type ToothSelectionGroupNormalized = { + groupId: string; + kind: 'connected' | 'single'; + teeth: string[]; +}; + +export function normalizeToothSelectionGroups( + value: unknown, + fallbackTeeth: string[] = [], +): ToothSelectionGroupNormalized[] { + if (Array.isArray(value) && value.length > 0) { + const out: ToothSelectionGroupNormalized[] = []; + for (const row of value) { + if (!row || typeof row !== 'object') continue; + const rec = row as Record; + const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : ''; + if (!groupId) continue; + const kind = rec.kind === 'connected' ? 'connected' : 'single'; + const teeth = normalizeTeeth(rec.teeth); + if (teeth.length === 0) continue; + out.push({ + groupId, + kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind, + teeth, + }); + } + if (out.length > 0) return out; + } + + return fallbackTeeth.map((tooth, index) => ({ + groupId: `legacy-${tooth}-${index}`, + kind: 'single' as const, + teeth: [tooth], + })); +} + export function generateTreatmentTitle( cases: { treatmentType: string; teeth: string[] }[], ): string { diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index de5ba9a..4572ea4 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; -import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client'; +import { AppException, ErrorCode } from '../../common/errors'; +import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -25,9 +27,11 @@ import { } from '../../common/lab-case-due-date'; import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { generateTreatmentTitle, normalizeTeeth, + normalizeToothSelectionGroups, } from './treatment.utils'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -49,7 +53,7 @@ const sentLabCaseInclude = { details: { include: { detail: { - select: { clientKey: true, treatmentType: true, teeth: true }, + select: { clientKey: true, treatmentType: true, teeth: true, toothSelectionGroups: true }, }, }, }, @@ -57,9 +61,9 @@ const sentLabCaseInclude = { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, - tasks: { select: { status: true } }, + tasks: { select: { status: true, selectionGroupId: true, prosthesisTypeCode: true, teeth: true } }, toothProsthesis: { - select: { tooth: true, prosthesisTypeCode: true }, + select: { tooth: true, prosthesisTypeCode: true, selectionGroupId: true }, }, } satisfies Prisma.LabCaseInclude; @@ -91,7 +95,13 @@ const treatmentInclude = { details: { include: { detail: { - select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + select: { + id: true, + clientKey: true, + treatmentType: true, + teeth: true, + toothSelectionGroups: true, + }, }, }, }, @@ -121,6 +131,7 @@ export class TreatmentsService { private readonly treatmentCatalog: TreatmentCatalogService, private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -281,17 +292,36 @@ export class TreatmentsService { const taskProgress = this.mapTaskProgress(lc.tasks); const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null; const detailTeeth = detail ? normalizeTeeth(detail.teeth) : []; + const selectionGroups = normalizeToothSelectionGroups( + detail?.toothSelectionGroups, + detailTeeth, + ); + const connectedGroupIds = new Set( + selectionGroups.filter((g) => g.kind === 'connected').map((g) => g.groupId), + ); - const prosthesisByCode = new Map(); + const prosthesisByGroup = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string; connected: boolean } + >(); for (const row of lc.toothProsthesis ?? []) { - const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? []; - list.push(row.tooth); - prosthesisByCode.set(row.prosthesisTypeCode, list); + const selectionGroupId = row.selectionGroupId || `legacy-${row.prosthesisTypeCode}`; + const key = `${selectionGroupId}::${row.prosthesisTypeCode}`; + const entry = prosthesisByGroup.get(key) ?? { + prosthesisTypeCode: row.prosthesisTypeCode, + teeth: [], + selectionGroupId, + connected: connectedGroupIds.has(row.selectionGroupId), + }; + entry.teeth.push(row.tooth); + prosthesisByGroup.set(key, entry); } - const prosthesisGroups = [...prosthesisByCode.entries()] - .map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - teeth: [...new Set(teeth)].sort(), + const prosthesisGroups = [...prosthesisByGroup.values()] + .map((group) => ({ + prosthesisTypeCode: group.prosthesisTypeCode, + teeth: [...new Set(group.teeth)].sort(), + connected: group.connected, + selectionGroupId: group.selectionGroupId, })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); @@ -375,13 +405,21 @@ export class TreatmentsService { this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } - const normalizedDetails = dto.details.map((d, index) => ({ - ...d, - sortOrder: index, - teeth: normalizeTeeth(d.teeth), - comment: d.comment?.trim() || null, - attachmentIds: d.attachmentIds ?? [], - })); + const normalizedDetails = dto.details.map((d, index) => { + const teeth = normalizeTeeth(d.teeth); + const toothSelectionGroups = normalizeToothSelectionGroups( + d.toothSelectionGroups, + teeth, + ); + return { + ...d, + sortOrder: index, + teeth, + toothSelectionGroups, + comment: d.comment?.trim() || null, + attachmentIds: d.attachmentIds ?? [], + }; + }); const title = generateTreatmentTitle( normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), @@ -452,6 +490,7 @@ export class TreatmentsService { sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, + toothSelectionGroups: d.toothSelectionGroups, comment: d.comment, }, }) @@ -462,6 +501,7 @@ export class TreatmentsService { sortOrder: d.sortOrder, treatmentType: d.treatmentType, teeth: d.teeth, + toothSelectionGroups: d.toothSelectionGroups, comment: d.comment, }, }); @@ -640,6 +680,7 @@ export class TreatmentsService { treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId?.trim() || '', })), }); } @@ -776,6 +817,17 @@ export class TreatmentsService { } }); + if (isFirstSend && labCase.destinationOrganizationId) { + void this.userNotifications.notify({ + organizationId: labCase.destinationOrganizationId, + type: UserNotificationType.CASE_SENT, + href: `/cases?caseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId }, + requiredPermission: 'TAB_CASES_READ', + }); + } + const refreshed = await this.prisma.labCase.findUniqueOrThrow({ where: { id: labCaseId }, include: { @@ -908,6 +960,25 @@ export class TreatmentsService { throw new BadRequestException('At least one file is required'); } + const existingDetail = await this.prisma.treatmentDetail.findFirst({ + where: { + clientKey: detailClientKey, + treatment: { + appointmentId, + organizationId, + ...treatmentProviderScopeWhere(actorUserId), + }, + }, + select: { + labCaseLink: { + select: { labCase: { select: { sentAt: true } } }, + }, + }, + }); + if (existingDetail?.labCaseLink?.labCase.sentAt) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT); + } + const orgDir = join(this.uploadRoot, organizationId); mkdirSync(orgDir, { recursive: true }); @@ -1070,6 +1141,7 @@ export class TreatmentsService { clientKey?: string | null; treatmentType: string; teeth: unknown; + toothSelectionGroups?: unknown; comment?: string | null; attachments?: Array<{ id: string; @@ -1093,11 +1165,13 @@ export class TreatmentsService { }) { const labCase = d.labCaseLink?.labCase; const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []); + const teeth = normalizeTeeth(d.teeth); return { id: d.id, clientId: d.clientKey ?? d.id, treatmentType: d.treatmentType, - teeth: normalizeTeeth(d.teeth), + teeth, + toothSelectionGroups: normalizeToothSelectionGroups(d.toothSelectionGroups, teeth), notes: d.comment ?? null, attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), labCaseId: labCase?.id ?? null, @@ -1122,7 +1196,13 @@ export class TreatmentsService { dueDate?: Date | null; details?: Array<{ treatmentDetailId: string; - detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + detail?: { + id: string; + clientKey: string | null; + treatmentType: string; + teeth: unknown; + toothSelectionGroups?: unknown; + }; }>; sends?: Array<{ organizationId: string; @@ -1133,6 +1213,7 @@ export class TreatmentsService { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; + selectionGroupId?: string; }>; attachments?: Array<{ attachment: { @@ -1146,6 +1227,9 @@ export class TreatmentsService { tasks?: Array<{ id: string; status: LabTaskStatus }>; }) { const taskProgress = this.mapTaskProgress(lc.tasks ?? []); + const detailTeeth = lc.details?.[0]?.detail + ? normalizeTeeth(lc.details[0].detail.teeth) + : []; return { id: lc.id, clientId: lc.clientKey ?? lc.id, @@ -1159,13 +1243,18 @@ export class TreatmentsService { id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId, clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId, treatmentType: lc.details[0].detail?.treatmentType ?? '', - teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [], + teeth: detailTeeth, + toothSelectionGroups: normalizeToothSelectionGroups( + lc.details[0].detail?.toothSelectionGroups, + detailTeeth, + ), } : null, toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({ treatmentDetailId: tp.treatmentDetailId, tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId ?? '', })), attachments: (lc.attachments ?? []).map((row) => ({ id: row.attachment.id, diff --git a/backend/src/realtime/realtime.emitter.ts b/backend/src/realtime/realtime.emitter.ts new file mode 100644 index 0000000..31f324d --- /dev/null +++ b/backend/src/realtime/realtime.emitter.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { Server } from 'socket.io'; + +export function userOrgRoom(userId: string, organizationId: string): string { + return `user:${userId}:org:${organizationId}`; +} + +@Injectable() +export class RealtimeEmitter { + private server: Server | null = null; + + setServer(server: Server) { + this.server = server; + } + + emitToUserOrg(userId: string, organizationId: string, event: string, payload: unknown) { + if (!this.server) return; + this.server.to(userOrgRoom(userId, organizationId)).emit(event, payload); + } +} diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts new file mode 100644 index 0000000..e1e0ad1 --- /dev/null +++ b/backend/src/realtime/realtime.gateway.ts @@ -0,0 +1,81 @@ +import { + OnGatewayConnection, + OnGatewayInit, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Server, Socket } from 'socket.io'; +import { RealtimeEmitter, userOrgRoom } from './realtime.emitter'; + +type AccessPayload = { + sub?: string; + organizationId?: string; + type?: string; +}; + +@WebSocketGateway({ + namespace: '/realtime', + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3001', + credentials: true, + }, +}) +export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection { + private readonly logger = new Logger(RealtimeGateway.name); + + @WebSocketServer() + server!: Server; + + constructor( + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly emitter: RealtimeEmitter, + ) {} + + afterInit(server: Server) { + this.emitter.setServer(server); + } + + async handleConnection(client: Socket) { + try { + const token = this.readAccessToken(client); + if (!token) { + client.disconnect(true); + return; + } + + const payload = await this.jwt.verifyAsync(token, { + secret: this.config.get('jwt.secret'), + }); + + if (!payload?.sub || !payload.organizationId || payload.type !== 'access') { + client.disconnect(true); + return; + } + + const room = userOrgRoom(payload.sub, payload.organizationId); + await client.join(room); + client.data.userId = payload.sub; + client.data.organizationId = payload.organizationId; + } catch (error) { + this.logger.debug(`Realtime auth failed: ${String(error)}`); + client.disconnect(true); + } + } + + private readAccessToken(client: Socket): string | null { + const cookieHeader = client.handshake.headers.cookie; + if (!cookieHeader) return null; + const parts = cookieHeader.split(';'); + for (const part of parts) { + const [rawKey, ...rest] = part.trim().split('='); + if (rawKey === 'accessToken') { + return decodeURIComponent(rest.join('=')); + } + } + return null; + } +} diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts new file mode 100644 index 0000000..e32fd2d --- /dev/null +++ b/backend/src/realtime/realtime.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RealtimeEmitter } from './realtime.emitter'; +import { RealtimeGateway } from './realtime.gateway'; + +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.secret'), + }), + }), + ], + providers: [RealtimeEmitter, RealtimeGateway], + exports: [RealtimeEmitter], +}) +export class RealtimeModule {} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b248bab..760b32c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -224,9 +224,9 @@ "subscriptionPeriodPercent": "{percent}%", "subscriptionPeriodDays": "{elapsed}/{total} days", "subscriptionNoPlan": "No active plan", - "chartAppointmentsWeekAllTitle": "Appointments This Week", + "chartAppointmentsWeekAllTitle": "Appointments", "chartAppointmentsWeekAllSubtitle": "All providers — last 7 days", - "chartAppointmentsWeekMineTitle": "My Appointments This Week", + "chartAppointmentsWeekMineTitle": "My Appointments", "chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days", "chartLabTaskActivityTitle": "Lab Task Activity", "chartLabTaskActivitySubtitle": "Last 7 days", @@ -245,7 +245,7 @@ "chartTreatmentPlanCompletionRatio": "With treatment plan", "chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis", "chartTasksByProsthesisSubtitle": "Current workload mix", - "chartCasesDueWeekTitle": "Cases Due This Week", + "chartCasesDueWeekTitle": "Cases Due", "chartCasesDueWeekSubtitle": "Active cases with a due date in the next 7 days", "chartCasePartnersClinicTitle": "Cases by Lab", "chartCasePartnersLabTitle": "Cases by Clinic", @@ -256,6 +256,10 @@ "chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days", "chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days", "chartEmpty": "No data for this period yet.", + "periodWeek": "Week", + "periodMonth": "Month", + "periodYear": "Year", + "periodSelectAria": "Chart time period", "upcomingAppointmentsTitle": "Upcoming Today", "upcomingAppointmentsSubtitle": "Appointments not yet finished", "viewAllAppointments": "View schedule", @@ -433,7 +437,7 @@ "statusCompleted": "Completed", "importantLabel": "Important", "unreadCase": "Unread updates", - "markCaseImportant": "Mark case as important", + "markCaseImportant": "Important Case", "markImportant": "Mark as important", "lastUpdatedBy": "Updated by {name}", "lastUpdatedUnknown": "Not started yet", @@ -472,7 +476,23 @@ "shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.", "copyShareLink": "Copy link", "shareLinkCopied": "Link copied to clipboard", - "shareQrUnavailable": "QR preview unavailable" + "shareQrUnavailable": "QR preview unavailable", + "downloadCaseSheet": "Download PDF", + "caseSheetTitle": "Case Sheet", + "caseSheetOrderNumber": "Order number", + "caseSheetOrderDate": "Order date", + "caseSheetDateDue": "Date due", + "caseSheetSender": "Sender", + "caseSheetRecipient": "Recipient", + "caseSheetPatient": "Patient", + "caseSheetPatientId": "Mobile", + "caseSheetProsthesis": "Prosthesis", + "caseSheetToothChart": "Tooth Chart", + "caseSheetComments": "Comments", + "caseSheetNoComments": "No comments.", + "errorDownloadCaseSheet": "Could not download the case sheet PDF.", + "externalCodePlaceholder": "external code e.g. exocad code", + "errorUpdateExternalCode": "Could not save the external code." }, "labCaseAccess": { "pageTitle": "Case tasks", @@ -573,6 +593,7 @@ "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", "loadingSchedule": "Loading schedule…", "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.", "errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.", "successUpdated": "Appointment updated.", @@ -582,6 +603,7 @@ "confirmRemove": "Remove this appointment?", "successRemoved": "Appointment removed.", "errorDelete": "Could not delete appointment.", + "deleteBlockedHint": "This appointment cannot be deleted because a treatment is linked to it.", "errorLoadSchedule": "Failed to load schedule.", "successPatientSaved": "Patient {firstName} {lastName} was saved.", "searchPlaceholder": "Search existing patients", @@ -659,14 +681,16 @@ "detailsTitle": "Treatment details", "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "addDetail": "Add detail", + "confirmRemoveDetail": "Remove this treatment detail?", + "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", "detailsSaveHint": "Lab dispatch is configured separately below.", "addCase": "Add case", "caseLabel": "Case {n}", - "comments": "Comments", - "commentsPlaceholder": "Write clinical notes for this case…", + "comments": "Notes", + "commentsPlaceholder": "Write clinical notes for this detail…", "treatmentType": "Treatment type", "treatmentTypePlaceholder": "Select treatment type…", "treatmentTypeNotSelected": "Type not selected", @@ -757,6 +781,7 @@ "activityClinicComment": "{actor}: “{preview}” · {date}", "activityLabComment": "{actor}: “{preview}” · {date}", "activityTaskCompleted": "{step} completed by {actor} · {date}", + "activityTaskAssigned": "{step} assigned by {actor} · {date}", "activityCaseImportant": "Marked important by {actor} · {date}", "activityCaseAmended": "Case updated by {actor} · {date}", "activityGeneric": "Update · {date}", @@ -797,8 +822,19 @@ "noActiveOrgs": "No active linked organizations.", "confirmSend": "Confirm send", "toothChartTitle": "FDI tooth chart", + "entryWizardLabel": "Treatment entry steps", + "entryStepTeeth": "Teeth", + "entryStepContent": "Content", + "entryStepLab": "Lab", + "entryStepBack": "Back", + "entryStepNext": "Next", + "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.", + "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", + "toothConnectedSuffix": " (connected)", + "connectedBadge": "Connected", + "prosthesisConnectedLabel": "Connected teeth prosthesis type", "toothChartWholePlan": "Full View", "labShipmentAttachments": "Files for the lab", "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", @@ -953,6 +989,36 @@ "monthNovember": "November", "monthDecember": "December" }, + "notifications": { + "bellAria": "Notifications", + "unreadCount": "{count} unread notifications", + "dropdownTitle": "Notifications", + "viewAll": "View all", + "pageTitle": "Notifications", + "pageSubtitle": "Updates from cases, tasks, organizations, and staff.", + "loading": "Loading notifications…", + "empty": "No notifications yet.", + "loadMore": "Load more", + "markAllRead": "Mark all as read", + "errorLoad": "Could not load notifications.", + "errorMarkRead": "Could not update notification.", + "typeCaseSent": "New lab case received", + "typeClinicComment": "New clinic comment on a case", + "typeLabComment": "New lab comment on a case", + "typeLabCommentClinic": "New lab comment on your case", + "typeCaseImportant": "Case marked as important", + "typeTaskCompleted": "Lab task completed", + "typeTaskAssigned": "A task was assigned to you", + "typeConnectionRequest": "New organization connection request", + "typeStaffInvite": "Staff invitation created", + "typeUnknown": "Notification", + "destCases": "Open Cases", + "destTasks": "Open Tasks", + "destTreatment": "Open Treatment", + "destOrganizations": "Open Organizations", + "destStaff": "Open Staff", + "destOpen": "Open" + }, "errors": { "GENERIC": "Something went wrong. Please try again.", "NETWORK_ERROR": "Could not reach the server. Check your connection and try again.", @@ -1013,6 +1079,8 @@ "CONFLICT": "This action conflicts with existing data.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", + "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", + "TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c3ee1ca..17ea3d7 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -224,9 +224,9 @@ "subscriptionPeriodPercent": "{percent}٪", "subscriptionPeriodDays": "{elapsed}/{total} روز", "subscriptionNoPlan": "اشتراک فعال نیست", - "chartAppointmentsWeekAllTitle": "نوبت‌های این هفته", + "chartAppointmentsWeekAllTitle": "نوبت‌ها", "chartAppointmentsWeekAllSubtitle": "همه ارائه‌دهندگان — ۷ روز گذشته", - "chartAppointmentsWeekMineTitle": "نوبت‌های من این هفته", + "chartAppointmentsWeekMineTitle": "نوبت‌های من", "chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته", "chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه", "chartLabTaskActivitySubtitle": "۷ روز گذشته", @@ -245,7 +245,7 @@ "chartTreatmentPlanCompletionRatio": "دارای طرح درمان", "chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز", "chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی", - "chartCasesDueWeekTitle": "پرونده‌های موعددار این هفته", + "chartCasesDueWeekTitle": "پرونده‌های موعددار", "chartCasesDueWeekSubtitle": "پرونده‌های فعال با موعد تحویل در ۷ روز آینده", "chartCasePartnersClinicTitle": "کیس‌ها بر اساس لابراتوار", "chartCasePartnersLabTitle": "کیس‌ها بر اساس کلینیک", @@ -256,6 +256,10 @@ "chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته", "chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته", "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.", + "periodWeek": "هفته", + "periodMonth": "ماه", + "periodYear": "سال", + "periodSelectAria": "بازه زمانی نمودار", "upcomingAppointmentsTitle": "نوبت‌های پیش رو", "upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز", "viewAllAppointments": "مشاهده برنامه", @@ -433,7 +437,7 @@ "statusCompleted": "انجام شده", "importantLabel": "مهم", "unreadCase": "به‌روزرسانی‌های خوانده‌نشده", - "markCaseImportant": "علامت‌گذاری پرونده به‌عنوان مهم", + "markCaseImportant": "پرونده مهم", "markImportant": "علامت‌گذاری به عنوان مهم", "lastUpdatedBy": "به‌روزرسانی توسط {name}", "lastUpdatedUnknown": "هنوز شروع نشده", @@ -473,7 +477,23 @@ "shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.", "copyShareLink": "کپی لینک", "shareLinkCopied": "لینک در کلیپ‌بورد کپی شد", - "shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست" + "shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست", + "downloadCaseSheet": "دانلود PDF", + "caseSheetTitle": "برگه پرونده", + "caseSheetOrderNumber": "شماره سفارش", + "caseSheetOrderDate": "تاریخ سفارش", + "caseSheetDateDue": "موعد تحویل", + "caseSheetSender": "فرستنده", + "caseSheetRecipient": "گیرنده", + "caseSheetPatient": "بیمار", + "caseSheetPatientId": "موبایل", + "caseSheetProsthesis": "پروتز", + "caseSheetToothChart": "نمودار دندان", + "caseSheetComments": "توضیحات", + "caseSheetNoComments": "بدون توضیحات.", + "errorDownloadCaseSheet": "دانلود PDF برگه پرونده ممکن نشد.", + "externalCodePlaceholder": "کد خارجی مثلاً کد exocad", + "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد." }, "labCaseAccess": { "pageTitle": "وظایف پرونده", @@ -574,6 +594,7 @@ "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", "loadingSchedule": "در حال بارگذاری برنامه...", "infoPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", + "infoEditBlockedHasTreatment": "این نوبت قابل ویرایش نیست چون یک درمان به آن متصل است.", "infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.", "errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائه‌دهنده است و قابل ویرایش نیست.", "successUpdated": "نوبت به‌روزرسانی شد.", @@ -583,6 +604,7 @@ "confirmRemove": "این نوبت حذف شود؟", "successRemoved": "نوبت حذف شد.", "errorDelete": "حذف نوبت امکان‌پذیر نبود.", + "deleteBlockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", "searchPlaceholder": "جستجوی بیماران موجود", @@ -660,14 +682,16 @@ "detailsTitle": "جزئیات درمان", "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "addDetail": "افزودن جزئیات", + "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", + "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.", "addCase": "افزودن پرونده", "caseLabel": "پرونده {n}", - "comments": "نظرات", - "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", + "comments": "یادداشت‌ها", + "commentsPlaceholder": "یادداشت‌های بالینی این جزئیات را بنویسید…", "treatmentType": "نوع درمان", "treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…", "treatmentTypeNotSelected": "نوع انتخاب نشده", @@ -758,6 +782,7 @@ "activityClinicComment": "{actor}: «{preview}» · {date}", "activityLabComment": "{actor}: «{preview}» · {date}", "activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}", + "activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}", "activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}", "activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}", "activityGeneric": "به‌روزرسانی · {date}", @@ -799,7 +824,18 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.", + "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل می‌سازد. کلیک روی دندان داخل پل آن را جدا می‌کند (همچنان انتخاب‌شده به‌صورت تک)؛ انتها یک پل می‌ماند، وسط به دو پل تقسیم می‌شود.", + "toothConnectedHint": "انتخاب متصل", + "toothConnectedSuffix": " (متصل)", + "connectedBadge": "متصل", + "prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل", + "entryWizardLabel": "مراحل ثبت درمان", + "entryStepTeeth": "دندان‌ها", + "entryStepContent": "محتوا", + "entryStepLab": "لابراتوار", + "entryStepBack": "قبلی", + "entryStepNext": "بعدی", + "entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.", "toothChartWholePlan": "نمایش کامل", "labShipmentAttachments": "فایل‌ها برای لابراتوار", "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", @@ -954,6 +990,36 @@ "monthNovember": "نوامبر", "monthDecember": "دسامبر" }, + "notifications": { + "bellAria": "اعلان‌ها", + "unreadCount": "{count} اعلان خوانده‌نشده", + "dropdownTitle": "اعلان‌ها", + "viewAll": "مشاهده همه", + "pageTitle": "اعلان‌ها", + "pageSubtitle": "به‌روزرسانی‌های پرونده‌ها، وظایف، سازمان‌ها و کارکنان.", + "loading": "در حال بارگذاری اعلان‌ها…", + "empty": "هنوز اعلانی نیست.", + "loadMore": "بیشتر", + "markAllRead": "علامت‌گذاری همه به‌عنوان خوانده‌شده", + "errorLoad": "بارگذاری اعلان‌ها ممکن نشد.", + "errorMarkRead": "به‌روزرسانی اعلان ممکن نشد.", + "typeCaseSent": "پرونده جدید در لابراتوار دریافت شد", + "typeClinicComment": "نظر جدید کلینیک روی پرونده", + "typeLabComment": "نظر جدید لابراتوار روی پرونده", + "typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما", + "typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد", + "typeTaskCompleted": "وظیفه لابراتوار تکمیل شد", + "typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد", + "typeConnectionRequest": "درخواست اتصال سازمان جدید", + "typeStaffInvite": "دعوتنامه کارکنان ایجاد شد", + "typeUnknown": "اعلان", + "destCases": "باز کردن پرونده‌ها", + "destTasks": "باز کردن وظایف", + "destTreatment": "باز کردن درمان", + "destOrganizations": "باز کردن سازمان‌ها", + "destStaff": "باز کردن کارکنان", + "destOpen": "باز کردن" + }, "errors": { "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", "NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.", @@ -1014,6 +1080,8 @@ "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", + "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", + "TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index fa4556f..bc00c67 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -224,9 +224,9 @@ "subscriptionPeriodPercent": "{percent}%", "subscriptionPeriodDays": "{elapsed}/{total} dagen", "subscriptionNoPlan": "Geen actief abonnement", - "chartAppointmentsWeekAllTitle": "Afspraken deze week", + "chartAppointmentsWeekAllTitle": "Afspraken", "chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen", - "chartAppointmentsWeekMineTitle": "Mijn afspraken deze week", + "chartAppointmentsWeekMineTitle": "Mijn afspraken", "chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen", "chartLabTaskActivityTitle": "Labtaakactiviteit", "chartLabTaskActivitySubtitle": "Afgelopen 7 dagen", @@ -245,7 +245,7 @@ "chartTreatmentPlanCompletionRatio": "Met behandelplan", "chartTasksByProsthesisTitle": "Lopende taken per prothese", "chartTasksByProsthesisSubtitle": "Huidige werklastmix", - "chartCasesDueWeekTitle": "Cases met deadline deze week", + "chartCasesDueWeekTitle": "Cases met deadline", "chartCasesDueWeekSubtitle": "Actieve cases met een deadline in de komende 7 dagen", "chartCasePartnersClinicTitle": "Cases per lab", "chartCasePartnersLabTitle": "Cases per kliniek", @@ -256,6 +256,10 @@ "chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen", "chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen", "chartEmpty": "Nog geen gegevens voor deze periode.", + "periodWeek": "Week", + "periodMonth": "Maand", + "periodYear": "Jaar", + "periodSelectAria": "Tijdvak grafiek", "upcomingAppointmentsTitle": "Komende afspraken vandaag", "upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond", "viewAllAppointments": "Bekijk planning", @@ -433,7 +437,7 @@ "statusCompleted": "Voltooid", "importantLabel": "Belangrijk", "unreadCase": "Ongelezen updates", - "markCaseImportant": "Zaak als belangrijk markeren", + "markCaseImportant": "Belangrijke zaak", "markImportant": "Markeren als belangrijk", "lastUpdatedBy": "Bijgewerkt door {name}", "lastUpdatedUnknown": "Nog niet gestart", @@ -473,7 +477,23 @@ "shareQrDialogSubtitle": "Scan de QR-code of kopieer de link om de taken van dit dossier te openen.", "copyShareLink": "Link kopiëren", "shareLinkCopied": "Link gekopieerd naar klembord", - "shareQrUnavailable": "QR-voorbeeld niet beschikbaar" + "shareQrUnavailable": "QR-voorbeeld niet beschikbaar", + "downloadCaseSheet": "PDF downloaden", + "caseSheetTitle": "Case Sheet", + "caseSheetOrderNumber": "Bestelnummer", + "caseSheetOrderDate": "Besteldatum", + "caseSheetDateDue": "Opleverdatum", + "caseSheetSender": "Afzender", + "caseSheetRecipient": "Ontvanger", + "caseSheetPatient": "Patiënt", + "caseSheetPatientId": "Mobiel", + "caseSheetProsthesis": "Prothese", + "caseSheetToothChart": "Tooth Chart", + "caseSheetComments": "Opmerkingen", + "caseSheetNoComments": "Geen opmerkingen.", + "errorDownloadCaseSheet": "Case Sheet PDF kon niet worden gedownload.", + "externalCodePlaceholder": "externe code bijv. exocad-code", + "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen." }, "labCaseAccess": { "pageTitle": "Dossiertaken", @@ -573,6 +593,7 @@ "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", "loadingSchedule": "Rooster laden...", "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.", "errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.", "successUpdated": "Afspraak bijgewerkt.", @@ -582,6 +603,7 @@ "confirmRemove": "Deze afspraak verwijderen?", "successRemoved": "Afspraak verwijderd.", "errorDelete": "Kon afspraak niet verwijderen.", + "deleteBlockedHint": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "errorLoadSchedule": "Rooster laden mislukt.", "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", "searchPlaceholder": "Bestaande patiënten zoeken", @@ -659,14 +681,16 @@ "detailsTitle": "Behandeldetails", "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "addDetail": "Detail toevoegen", + "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", + "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.", "addCase": "Case toevoegen", "caseLabel": "Case {n}", - "comments": "Opmerkingen", - "commentsPlaceholder": "Schrijf klinische notities voor deze case...", + "comments": "Notities", + "commentsPlaceholder": "Schrijf klinische notities voor dit detail…", "treatmentType": "Behandeltype", "treatmentTypePlaceholder": "Selecteer behandeltype…", "treatmentTypeNotSelected": "Type niet geselecteerd", @@ -757,6 +781,7 @@ "activityClinicComment": "{actor}: “{preview}” · {date}", "activityLabComment": "{actor}: “{preview}” · {date}", "activityTaskCompleted": "{step} voltooid door {actor} · {date}", + "activityTaskAssigned": "{step} toegewezen door {actor} · {date}", "activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}", "activityCaseAmended": "Case bijgewerkt door {actor} · {date}", "activityGeneric": "Update · {date}", @@ -798,7 +823,18 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.", + "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", + "toothConnectedSuffix": " (verbonden)", + "connectedBadge": "Verbonden", + "prosthesisConnectedLabel": "Prothesetype voor verbonden tanden", + "entryWizardLabel": "Behandeling invoerstappen", + "entryStepTeeth": "Tanden", + "entryStepContent": "Inhoud", + "entryStepLab": "Lab", + "entryStepBack": "Terug", + "entryStepNext": "Volgende", + "entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.", "toothChartWholePlan": "Volledig overzicht", "labShipmentAttachments": "Bestanden voor het lab", "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", @@ -953,6 +989,36 @@ "monthNovember": "November", "monthDecember": "December" }, + "notifications": { + "bellAria": "Meldingen", + "unreadCount": "{count} ongelezen meldingen", + "dropdownTitle": "Meldingen", + "viewAll": "Alles bekijken", + "pageTitle": "Meldingen", + "pageSubtitle": "Updates van cases, taken, organisaties en personeel.", + "loading": "Meldingen laden…", + "empty": "Nog geen meldingen.", + "loadMore": "Meer laden", + "markAllRead": "Alles als gelezen markeren", + "errorLoad": "Meldingen konden niet worden geladen.", + "errorMarkRead": "Melding kon niet worden bijgewerkt.", + "typeCaseSent": "Nieuwe labcase ontvangen", + "typeClinicComment": "Nieuwe kliniekreactie op een case", + "typeLabComment": "Nieuwe labreactie op een case", + "typeLabCommentClinic": "Nieuwe labreactie op uw case", + "typeCaseImportant": "Case gemarkeerd als belangrijk", + "typeTaskCompleted": "Labtaak voltooid", + "typeTaskAssigned": "Er is een taak aan u toegewezen", + "typeConnectionRequest": "Nieuw organisatieverzoek", + "typeStaffInvite": "Personeelsuitnodiging aangemaakt", + "typeUnknown": "Melding", + "destCases": "Cases openen", + "destTasks": "Taken openen", + "destTreatment": "Behandeling openen", + "destOrganizations": "Organisaties openen", + "destStaff": "Personeel openen", + "destOpen": "Openen" + }, "errors": { "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", "NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.", @@ -1013,6 +1079,8 @@ "CONFLICT": "Deze actie conflicteert met bestaande gegevens.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", + "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", + "TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 89c31d7..31dfa4d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "js-cookie": "^3.0.5", + "jspdf": "^4.2.1", "lucide-react": "^0.577.0", "next": "16.1.6", "next-intl": "^4.13.0", @@ -20,6 +21,7 @@ "react-hook-form": "^7.71.2", "react-qr-code": "^2.0.15", "recharts": "^3.9.2", + "socket.io-client": "^4.8.3", "zod": "^4.3.6" }, "devDependencies": { @@ -241,6 +243,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", @@ -1627,6 +1638,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2264,6 +2281,19 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", @@ -2285,6 +2315,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3178,6 +3215,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.7", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", @@ -3328,6 +3375,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -3406,6 +3473,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3421,6 +3500,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -3614,7 +3703,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3708,6 +3796,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3736,6 +3834,28 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -4446,6 +4566,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", @@ -4456,6 +4587,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4870,6 +5007,20 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/icu-minify": { "version": "4.13.0", "resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz", @@ -4966,6 +5117,12 @@ "@formatjs/icu-messageformat-parser": "3.5.11" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5496,6 +5653,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5962,7 +6136,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6395,6 +6568,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -6435,6 +6624,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -6563,6 +6759,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", @@ -6716,6 +6922,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6795,6 +7008,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7096,6 +7319,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7112,6 +7363,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7311,6 +7572,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -7332,6 +7603,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7707,6 +7988,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -7844,6 +8135,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0ede802..ba8e631 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "js-cookie": "^3.0.5", + "jspdf": "^4.2.1", "lucide-react": "^0.577.0", "next": "16.1.6", "next-intl": "^4.13.0", @@ -21,6 +22,7 @@ "react-hook-form": "^7.71.2", "react-qr-code": "^2.0.15", "recharts": "^3.9.2", + "socket.io-client": "^4.8.3", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index c3dc289..4c211da 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; +import { NotificationBell } from '@/components/ui/notifications/NotificationBell'; import { ToastProvider } from '@/components/ui/shared/ToastProvider'; +import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider'; import { canAccessDashboardRoute, firstAccessibleDashboardPath, @@ -76,8 +78,18 @@ 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 ( +
+ {t('loadingWorkspace')} +
+ ); + } + return ( +
{sidebarOpen ? (
+
); } @@ -132,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
+
diff --git a/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx b/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..6490613 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage'; + +export default function NotificationsRoutePage() { + return ; +} diff --git a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx index a9c4d5d..bc49ade 100644 --- a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx @@ -10,6 +10,7 @@ export default function TreatmentPage() { const { user, currentOrganization, isAuthReady } = useAuth(); const searchParams = useSearchParams(); const initialAppointmentId = searchParams.get('appointmentId'); + const initialLabCaseId = searchParams.get('labCaseId'); if (!isAuthReady || !user) { return ( @@ -22,6 +23,7 @@ export default function TreatmentPage() { userId={user.id} currentOrganization={currentOrganization} initialAppointmentId={initialAppointmentId} + initialLabCaseId={initialLabCaseId} /> ); } diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 443a137..6ad568e 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -148,7 +148,7 @@ function AcceptInviteContent() { onChange={(e) => setConfirmPassword(e.target.value)} passwordToggleLabels={passwordToggleLabels} /> - diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 6362110..213af59 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -244,7 +244,7 @@ function AcceptOrganizationInviteContent() { icon={} passwordToggleLabels={passwordToggleLabels} /> - diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx index 29eca1d..1f2aa19 100644 --- a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -182,7 +182,7 @@ export default function ForgotPasswordPage() { variant="primary" isLoading={isSending} fullWidth - onClick={() => void onSendCode()} + onClick={() => onSendCode()} > {t('sendCode')} diff --git a/frontend/src/app/[locale]/(public)/page.tsx b/frontend/src/app/[locale]/(public)/page.tsx index f000421..5f620c9 100644 --- a/frontend/src/app/[locale]/(public)/page.tsx +++ b/frontend/src/app/[locale]/(public)/page.tsx @@ -11,10 +11,10 @@ export default function HomePage() { const t = useTranslations('landing'); const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); - const { user } = useAuth(); + const { user, isAuthReady } = useAuth(); return ( -
+
@@ -23,7 +23,9 @@ export default function HomePage() {
- {user ? ( + {!isAuthReady ? ( + + ) : user ? (
-
+

{t('heroTitle')} @@ -59,7 +61,7 @@ export default function HomePage() { {t('heroSubtitle')}

- {!user && ( + {isAuthReady && !user && (

-