improvement: QRcode and shared link added to cases inorder to make it possible for staff users to share a case info with other staffs or other clinics.

This commit is contained in:
2026-07-14 21:07:05 +03:30
parent 953e609fb8
commit 08df2f71ea
41 changed files with 1535 additions and 83 deletions

View File

@@ -33,3 +33,7 @@ Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` +
## Today dashboard ## Today dashboard
CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `components/today/today-deep-links.ts`. Task KPIs need `TAB_TASKS_READ` (no owner bypass). Skill: `.cursor/skills/today-dashboard/SKILL.md`. CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `components/today/today-deep-links.ts`. Task KPIs need `TAB_TASKS_READ` (no owner bypass). Skill: `.cursor/skills/today-dashboard/SKILL.md`.
## 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`.

View File

@@ -0,0 +1,18 @@
---
description: Lab case QR share link — token, access API, focus page, auth redirect
globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts
alwaysApply: false
---
# Lab case share link
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent.
- **QR:** client-side `react-qr-code` in frontend — ❌ no backend QR endpoint.
- **Cases panel:** attachment preview left, QR thumb right, one row; dialog for QR + copy link.
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login consumes **once** after org ready — ❌ do not consume in `useAuth.login()`.
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`

View File

@@ -1,6 +1,6 @@
--- ---
description: Lab Cases tab — prosthesis filter, auto-select, list cards description: Lab Cases tab — prosthesis filter, auto-select, list cards
globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/**
alwaysApply: false alwaysApply: false
--- ---
@@ -10,5 +10,6 @@ alwaysApply: false
- **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides. - **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides.
- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. - **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card.
- **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab. - **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab.
- **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`.
Skill: `.cursor/skills/lab-cases/SKILL.md` Skill: `.cursor/skills/lab-cases/SKILL.md`

View File

@@ -11,6 +11,8 @@ alwaysApply: false
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. - **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
- **Important first:** `pinImportant=true` (sort pin, not filter). - **Important first:** `pinImportant=true` (sort pin, not filter).
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”. - **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
- **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments.
- **Share link page** (`/lab-case/[token]`): reuses `TaskRow` + assignee rules; comments via token API — see `.cursor/skills/lab-case-share-link/SKILL.md`.
- **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks. - **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks.
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks. - **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. - **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.

View File

@@ -0,0 +1,74 @@
---
name: dyolink-lab-case-share-link
description: Lab case QR share link — access token, focus page, auth redirect, Cases QR UI. Use when changing share links, /lab-case/[token], lab-case-access API, or post-login redirect from share URLs.
---
# Lab case share link
Shipped cases get a stable **access token** and share URL. QR + link open a focused tasks page with comments — not a public page; JWT + org context required.
## Data & token lifecycle
- **Schema:** `LabCase.accessToken` (`String?`, `@unique`).
- **On first ship:** `treatments.service` sets `accessToken` + `sentAt` in the same update.
- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`.
- **URL:** `buildLabCaseShareUrl(token, locale)``{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`).
## Backend API (`LabCaseAccessController`)
Base path: `/lab-cases/access/:token` (JWT + selected org required).
| Route | Purpose |
|-------|---------|
| `GET :token` | Session metadata (access mode, permissions, patient, prosthesis groups) |
| `GET :token/tasks` | All case tasks (includes assignee for status rules) |
| `GET/POST :token/comments` | List / add comments |
| `PATCH :token/comments/:id/visibility` | Lab only — clinic visibility toggle |
**Access resolution** (`lab-case-access.service.ts`):
| Actor | View | Edit task status | Comments |
|-------|------|------------------|----------|
| Lab + `TAB_TASKS_READ`/`EDIT` | ✅ | ✅ if `TAB_TASKS_EDIT` + assignee rules | Post/toggle if `TAB_TASKS_EDIT` |
| Clinic + `TAB_TREATMENT_EDIT` + **treatment provider** | ✅ | ❌ read-only | Post only (no visibility toggle) |
| Everyone else | ❌ `LAB_CASE_ACCESS_DENIED` | | |
Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assignee rule as Tasks tab: unassigned or assigned-to-you only.
## Frontend
| Piece | Path |
|-------|------|
| Focus page | `app/[locale]/(dashboard)/lab-case/[token]/page.tsx``CaseTasksFocusView` |
| API client | `lib/api/lab-case-access.ts` |
| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` |
| QR package | `react-qr-code` (frontend only — no backend QR generation) |
**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`).
## Auth redirect (logged out → login → back)
Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)`.
3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected).
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`.
5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it.
## Tasks tab interaction
Grouped sort (`sortBy=date`): **one comments control on case header** (`expandedCommentsCaseId`), not per task row. Flat sort unchanged (`showCommentsButton={flatMode}`).
## i18n
- `cases.*` — QR dialog strings (`shareQrDialogTitle`, `copyShareLink`, …)
- `labCaseAccess.*` — focus page strings
- `errors.LAB_CASE_ACCESS_DENIED` — all three locales
## Verify
- Backend: `npm run build`; apply migration for `accessToken`.
- Frontend: `npx tsc --noEmit`; `next build` (login page Suspense for `useSearchParams`).

View File

@@ -1,6 +1,6 @@
--- ---
name: dyolink-lab-cases name: dyolink-lab-cases
description: Lab Cases tab — list, filters, detail panel, assignment, card UX. Use when changing CasesPage, cases API, or case list cards. description: Lab Cases tab — list, filters, detail panel, assignment, share QR, card UX. Use when changing CasesPage, cases API, CaseDetailPanel, or case list cards.
--- ---
# Lab Cases tab # Lab Cases tab
@@ -36,6 +36,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) - Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes - Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) - Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
## Permissions ## Permissions

View File

@@ -26,6 +26,8 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`).
## Prosthesis colors ## Prosthesis colors
- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code). - Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code).
@@ -48,7 +50,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row. **Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row.
**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Managers assign/monitor in Cases. **Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Same rules on **lab case share link** page (`CaseTasksFocusView` + `canEditLabTaskStatus`). Managers assign/monitor in Cases.
**Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). **Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden).

View File

@@ -51,7 +51,13 @@ frontend/src/
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. See `.cursor/skills/lab-cases/SKILL.md`. **Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab case share link (quick ref):**
- Token on first ship → `/{locale}/lab-case/{token}` after login.
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
- **Clinic:** treatment **provider** only — read-only tasks, can comment.
- Logged out → login with `?from=` → single `consumeAuthRedirect()` after org ready (not inside `useAuth.login()`).
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`. **Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
@@ -79,6 +85,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| `.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 |
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.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, list cards, assignment |
| `.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/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |

View File

@@ -41,6 +41,8 @@
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"qrcode": "^1.5.4",
"react-qr-code": "^2.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"styled-components": "^6.3.11", "styled-components": "^6.3.11",
@@ -59,6 +61,7 @@
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36", "@types/styled-components": "^5.1.36",
@@ -6290,6 +6293,16 @@
"pg-types": "^2.2.0" "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": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -7540,7 +7553,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7550,7 +7562,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -8163,7 +8174,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -8441,7 +8451,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -8454,7 +8463,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
@@ -8777,6 +8785,15 @@
} }
} }
}, },
"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": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -8890,6 +8907,12 @@
"node": ">=0.3.1" "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": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -9008,7 +9031,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -10052,7 +10074,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -10670,7 +10691,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -12875,7 +12895,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -13189,6 +13208,15 @@
"node": ">=4" "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": { "node_modules/polished": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
@@ -13607,6 +13635,133 @@
], ],
"license": "MIT" "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": { "node_modules/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -13816,6 +13971,19 @@
"react-dom": "^16.8.0 || ^17 || ^18" "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": { "node_modules/react-redux": {
"version": "8.1.3", "version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
@@ -14041,7 +14209,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -14057,6 +14224,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -14341,6 +14514,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -14647,7 +14826,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -14678,7 +14856,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -16169,6 +16346,12 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16190,7 +16373,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",

View File

@@ -80,8 +80,6 @@
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36", "@types/styled-components": "^5.1.36",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "lab_cases" ADD COLUMN "accessToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "lab_cases_accessToken_key" ON "lab_cases"("accessToken");

View File

@@ -217,6 +217,7 @@ model LabCase {
sentAt DateTime? sentAt DateTime?
dueDate DateTime? dueDate DateTime?
isImportant Boolean @default(false) isImportant Boolean @default(false)
accessToken String? @unique
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[] details LabCaseDetail[]

View File

@@ -42,6 +42,7 @@ export const ErrorCode = {
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF', PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF', PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND', PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
LAB_CASE_ACCESS_DENIED: 'LAB_CASE_ACCESS_DENIED',
// Validation // Validation
VALIDATION_FAILED: 'VALIDATION_FAILED', VALIDATION_FAILED: 'VALIDATION_FAILED',

View File

@@ -0,0 +1,11 @@
import { randomBytes } from 'crypto';
export function generateLabCaseAccessToken(): string {
return randomBytes(32).toString('base64url');
}
export function buildLabCaseShareUrl(accessToken: string, locale = 'en'): string {
const appUrl = (process.env.FRONTEND_URL || 'http://localhost:3001').replace(/\/$/, '');
const normalizedLocale = locale.trim() || 'en';
return `${appUrl}/${normalizedLocale}/lab-case/${accessToken}`;
}

View File

@@ -3,13 +3,18 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module'; import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { TasksModule } from '../tasks/tasks.module';
import { CatalogModule } from '../catalog/catalog.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { CasesController } from './cases.controller'; import { CasesController } from './cases.controller';
import { CasesService } from './cases.service'; import { CasesService } from './cases.service';
import { LabCaseAccessController } from './lab-case-access.controller';
import { LabCaseAccessService } from './lab-case-access.service';
@Module({ @Module({
imports: [ProsthesisCatalogModule, NotificationsModule], imports: [ProsthesisCatalogModule, NotificationsModule, TasksModule, CatalogModule, LabCaseCommentsModule],
controllers: [CasesController], controllers: [LabCaseAccessController, CasesController],
providers: [CasesService, PrismaService, LabOrgGuard], providers: [CasesService, LabCaseAccessService, PrismaService, LabOrgGuard],
exports: [CasesService], exports: [CasesService, LabCaseAccessService],
}) })
export class CasesModule {} export class CasesModule {}

View File

@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions'; import { hasEffectivePermission } from '../../common/membership-permissions';
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { LabCaseAccessService } from './lab-case-access.service';
const labCaseListInclude = { const labCaseListInclude = {
treatment: { treatment: {
@@ -95,6 +96,7 @@ export class CasesService {
private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly catalogLabels: CatalogLabelService, private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService, private readonly labCaseActivity: LabCaseActivityService,
private readonly labCaseAccess: LabCaseAccessService,
) {} ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -605,6 +607,12 @@ export class CasesService {
locale, locale,
); );
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels); const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
const accessToken = lc.sentAt
? await this.labCaseAccess.ensureAccessToken(lc.id)
: null;
const shareUrl = accessToken
? this.labCaseAccess.buildShareUrl(accessToken, locale)
: null;
return { return {
id: lc.id, id: lc.id,
@@ -612,6 +620,7 @@ export class CasesService {
dueDate: lc.dueDate?.toISOString() ?? null, dueDate: lc.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
isImportant: lc.isImportant, isImportant: lc.isImportant,
shareUrl,
clinic: lc.treatment.organization, clinic: lc.treatment.organization,
patient: lc.treatment.patient, patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,

View File

@@ -0,0 +1,89 @@
import { Body, Controller, Get, HttpStatus, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseAccessService } from './lab-case-access.service';
import {
CreateLabCaseCommentDto,
SetCommentVisibilityDto,
} from '../lab-case-comments/dto/lab-case-comment.dto';
@ApiTags('lab-cases')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('lab-cases/access')
export class LabCaseAccessController {
constructor(private readonly accessService: LabCaseAccessService) {}
private requireOrganizationId(req: { user?: { organizationId?: string } }): string {
const organizationId = req.user?.organizationId;
if (!organizationId) {
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
return organizationId;
}
@Get(':token')
@ApiOperation({ summary: 'Resolve a lab case share link for the current user' })
resolve(@Param('token') token: string, @Req() req) {
return this.accessService.resolveByToken(
token,
req.user.id,
this.requireOrganizationId(req),
req.user.language,
);
}
@Get(':token/tasks')
@ApiOperation({ summary: 'List all tasks for a lab case share link' })
listTasks(@Param('token') token: string, @Req() req) {
return this.accessService.listTasksByToken(
token,
req.user.id,
this.requireOrganizationId(req),
req.user.language,
);
}
@Get(':token/comments')
@ApiOperation({ summary: 'List comments for a lab case share link' })
listComments(@Param('token') token: string, @Req() req) {
return this.accessService.listCommentsByToken(
token,
req.user.id,
this.requireOrganizationId(req),
);
}
@Post(':token/comments')
@ApiOperation({ summary: 'Add a comment via a lab case share link' })
addComment(
@Param('token') token: string,
@Body() dto: CreateLabCaseCommentDto,
@Req() req,
) {
return this.accessService.addCommentByToken(
token,
req.user.id,
this.requireOrganizationId(req),
dto,
);
}
@Patch(':token/comments/:commentId/visibility')
@ApiOperation({ summary: 'Toggle clinic visibility for a lab comment on a share link' })
setCommentVisibility(
@Param('token') token: string,
@Param('commentId') commentId: string,
@Body() dto: SetCommentVisibilityDto,
@Req() req,
) {
return this.accessService.setCommentVisibilityByToken(
token,
commentId,
req.user.id,
this.requireOrganizationId(req),
dto.visibleToClinic,
);
}
}

View File

@@ -0,0 +1,329 @@
import {
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
buildLabCaseShareUrl,
generateLabCaseAccessToken,
} from '../../common/lab-case-access-token';
import { AppException, ErrorCode } from '../../common/errors';
import { hasEffectivePermission } from '../../common/membership-permissions';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { isLabCaseOverdue } from '../../common/lab-case-due-date';
import { TasksService } from '../tasks/tasks.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
const accessCaseInclude = {
treatment: {
select: {
providerUserId: true,
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true } },
appointment: { select: { providerUserId: true } },
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: { select: { status: true } },
} satisfies Prisma.LabCaseInclude;
type ResolvedAccess =
| {
kind: 'lab';
canEditTaskStatus: boolean;
canPostComments: boolean;
canToggleCommentVisibility: boolean;
}
| {
kind: 'clinic';
canEditTaskStatus: false;
canPostComments: boolean;
canToggleCommentVisibility: false;
}
| { kind: 'denied' };
@Injectable()
export class LabCaseAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly tasksService: TasksService,
private readonly commentsService: LabCaseCommentsService,
) {}
async ensureAccessToken(labCaseId: string): Promise<string | null> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: labCaseId },
select: { sentAt: true, accessToken: true },
});
if (!labCase?.sentAt) return null;
if (labCase.accessToken) return labCase.accessToken;
const accessToken = generateLabCaseAccessToken();
await this.prisma.labCase.update({
where: { id: labCaseId },
data: { accessToken },
});
return accessToken;
}
buildShareUrl(accessToken: string, locale?: string | null): string {
return buildLabCaseShareUrl(accessToken, locale ?? 'en');
}
async resolveByToken(
token: string,
actorUserId: string,
organizationId: string,
localeInput?: string | null,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [
...new Set(
(
await this.prisma.labCaseTask.findMany({
where: { labCaseId: labCase.id },
select: { prosthesisTypeCode: true },
})
)
.map((t) => t.prosthesisTypeCode)
.filter(Boolean),
),
];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
const prosthesisGroups = await this.buildProsthesisGroups(labCase.id, prosthesisLabels);
return {
success: true,
data: {
labCaseId: labCase.id,
accessMode: access.kind,
canEditTaskStatus: access.canEditTaskStatus,
canPostComments: access.canPostComments,
canToggleCommentVisibility: access.canToggleCommentVisibility,
shareUrl: this.buildShareUrl(token, locale),
sentAt: labCase.sentAt?.toISOString() ?? null,
dueDate: labCase.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks),
isImportant: labCase.isImportant,
clinic: labCase.treatment.organization,
lab: labCase.sends[0]?.organization ?? null,
patient: labCase.treatment.patient,
prosthesisGroups,
},
};
}
async listTasksByToken(
token: string,
actorUserId: string,
organizationId: string,
localeInput?: string | null,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
return this.tasksService.listTasksForLabCaseId(labCase.id, localeInput);
}
async listCommentsByToken(
token: string,
actorUserId: string,
organizationId: string,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
if (access.kind === 'lab') {
return this.commentsService.listForLabViewer(labCase.id, organizationId, actorUserId);
}
return this.commentsService.listForClinicTreatmentCase(
labCase.id,
organizationId,
actorUserId,
);
}
async addCommentByToken(
token: string,
actorUserId: string,
organizationId: string,
dto: CreateLabCaseCommentDto,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied' || !access.canPostComments) {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
if (access.kind === 'lab') {
return this.commentsService.addForLab(labCase.id, organizationId, actorUserId, dto);
}
return this.commentsService.addForClinicTreatmentCase(
labCase.id,
organizationId,
actorUserId,
dto,
);
}
async setCommentVisibilityByToken(
token: string,
commentId: string,
actorUserId: string,
organizationId: string,
visibleToClinic: boolean,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind !== 'lab' || !access.canToggleCommentVisibility) {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
return this.commentsService.setVisibility(
commentId,
organizationId,
actorUserId,
visibleToClinic,
);
}
private async findCaseByToken(token: string) {
const labCase = await this.prisma.labCase.findFirst({
where: { accessToken: token, sentAt: { not: null } },
include: accessCaseInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return labCase;
}
private async resolveAccess(
labCase: Prisma.LabCaseGetPayload<{ include: typeof accessCaseInclude }>,
actorUserId: string,
organizationId: string,
): Promise<ResolvedAccess> {
const clinicOrgId = labCase.treatment.organization.id;
const labOrgId =
labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null;
if (!labOrgId) {
return { kind: 'denied' };
}
if (organizationId === labOrgId) {
const membership = await this.getMembership(actorUserId, labOrgId);
if (!membership) return { kind: 'denied' };
if (
hasEffectivePermission(membership, 'TAB_TASKS_READ') ||
hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
) {
const canEdit = hasEffectivePermission(membership, 'TAB_TASKS_EDIT');
return {
kind: 'lab',
canEditTaskStatus: canEdit,
canPostComments: canEdit,
canToggleCommentVisibility: canEdit,
};
}
return { kind: 'denied' };
}
if (organizationId === clinicOrgId) {
const membership = await this.getMembership(actorUserId, clinicOrgId);
if (!membership) return { kind: 'denied' };
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
return { kind: 'denied' };
}
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
return { kind: 'denied' };
}
return {
kind: 'clinic',
canEditTaskStatus: false,
canPostComments: true,
canToggleCommentVisibility: false,
};
}
return { kind: 'denied' };
}
private async buildProsthesisGroups(
labCaseId: string,
prosthesisLabels: Map<string, string>,
) {
const tasks = await this.prisma.labCaseTask.findMany({
where: { labCaseId },
select: { prosthesisTypeCode: true, teeth: true },
orderBy: [{ prosthesisTypeCode: 'asc' }],
});
const byCode = new Map<string, string[]>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const teeth = normalizeTaskTeeth(task.teeth);
const list = byCode.get(task.prosthesisTypeCode) ?? [];
list.push(...teeth);
byCode.set(task.prosthesisTypeCode, list);
}
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 })),
}));
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}

View File

@@ -34,6 +34,12 @@ export class LabCaseCommentsService {
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
} }
async listForLabViewer(caseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
const comments = await this.fetchComments(caseId);
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
}
async addForLab( async addForLab(
caseId: string, caseId: string,
labOrganizationId: string, labOrganizationId: string,
@@ -219,6 +225,18 @@ export class LabCaseCommentsService {
caseId: string, caseId: string,
labOrganizationId: string, labOrganizationId: string,
actorUserId: string, actorUserId: string,
) {
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
}
private async assertLabCanViewCase(
caseId: string,
labOrganizationId: string,
actorUserId: string,
) { ) {
const labCase = await this.prisma.labCase.findFirst({ const labCase = await this.prisma.labCase.findFirst({
where: { where: {
@@ -232,10 +250,20 @@ export class LabCaseCommentsService {
throw new NotFoundException('Case not found'); throw new NotFoundException('Case not found');
} }
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
if (
!hasEffectivePermission(membership, 'TAB_TASKS_READ') &&
!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
) {
throw new ForbiddenException('You do not have access to tasks');
}
}
private async getLabMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { where: {
userId: actorUserId, userId,
organizationId: labOrganizationId, organizationId,
OR: [{ isOwner: true }, { isActive: true }], OR: [{ isOwner: true }, { isActive: true }],
}, },
include: { include: {
@@ -246,9 +274,7 @@ export class LabCaseCommentsService {
if (!membership) { if (!membership) {
throw new ForbiddenException('You are not a member of this organization'); throw new ForbiddenException('You are not a member of this organization');
} }
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { return membership;
throw new ForbiddenException('You do not have access to task comments');
}
} }
private async assertClinicOwnsCase( private async assertClinicOwnsCase(

View File

@@ -8,5 +8,6 @@ import { TasksService } from './tasks.service';
imports: [CatalogModule, NotificationsModule], imports: [CatalogModule, NotificationsModule],
controllers: [TasksController], controllers: [TasksController],
providers: [TasksService], providers: [TasksService],
exports: [TasksService],
}) })
export class TasksModule {} export class TasksModule {}

View File

@@ -95,6 +95,58 @@ export class TasksService {
}; };
} }
async listForLabCase(
labCaseId: string,
organizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadTasks(actorUserId, organizationId);
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId } },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return this.listTasksForLabCaseId(labCaseId, localeInput);
}
async listTasksForLabCaseId(labCaseId: string, localeInput?: string | null) {
const items = await this.prisma.labCaseTask.findMany({
where: { labCaseId },
include: taskListInclude,
orderBy: [
{ treatmentDetailId: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
],
});
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
return {
success: true,
data: {
items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
},
};
}
async locateTaskPage( async locateTaskPage(
labOrganizationId: string, labOrganizationId: string,
actorUserId: string, actorUserId: string,

View File

@@ -8,6 +8,7 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/
import { createReadStream, existsSync, mkdirSync } from 'fs'; import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator'; import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
@@ -754,7 +755,10 @@ export class TreatmentsService {
if (!labCase.sentAt) { if (!labCase.sentAt) {
await tx.labCase.update({ await tx.labCase.update({
where: { id: labCaseId }, where: { id: labCaseId },
data: { sentAt: now }, data: {
sentAt: now,
accessToken: generateLabCaseAccessToken(),
},
}); });
} }

View File

@@ -466,7 +466,22 @@
"prevPage": "Previous", "prevPage": "Previous",
"nextPage": "Next", "nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)", "pageSummary": "Page {page} of {totalPages} ({total} cases)",
"statusLabel": "Status" "statusLabel": "Status",
"viewShareQr": "View case QR code and share link",
"shareQrDialogTitle": "Case share link",
"shareQrDialogSubtitle": "Scan the QR code or copy the link to open this cases tasks.",
"copyShareLink": "Copy link",
"shareLinkCopied": "Link copied to clipboard",
"shareQrUnavailable": "QR preview unavailable"
},
"labCaseAccess": {
"pageTitle": "Case tasks",
"loading": "Loading case…",
"accessDeniedTitle": "Access denied",
"accessDenied": "You do not have permission to view this case. Sign in with a lab or clinic account that has access, and make sure you are the treatment provider for clinic access.",
"emptyTasks": "No tasks found for this case.",
"caseSummary": "{clinic} · {patient}",
"labName": "Lab: {name}"
}, },
"tasks": { "tasks": {
"title": "Tasks", "title": "Tasks",
@@ -978,6 +993,7 @@
"PERMISSION_ACCESS_TASKS": "You do not have access to tasks.", "PERMISSION_ACCESS_TASKS": "You do not have access to tasks.",
"PERMISSION_EDIT_TASKS": "You cannot edit tasks.", "PERMISSION_EDIT_TASKS": "You cannot edit tasks.",
"PERMISSION_ACCESS_CASES": "You do not have access to cases.", "PERMISSION_ACCESS_CASES": "You do not have access to cases.",
"LAB_CASE_ACCESS_DENIED": "You do not have permission to view this shared case.",
"PERMISSION_ACCESS_STAFF": "You do not have access to staff management.", "PERMISSION_ACCESS_STAFF": "You do not have access to staff management.",
"PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.", "PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.",
"PERMISSION_ORG_NOT_FOUND": "Organization not found.", "PERMISSION_ORG_NOT_FOUND": "Organization not found.",

View File

@@ -467,7 +467,22 @@
"prevPage": "قبلی", "prevPage": "قبلی",
"nextPage": "بعدی", "nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
"statusLabel": "وضعیت" "statusLabel": "وضعیت",
"viewShareQr": "مشاهده QR و لینک اشتراک‌گذاری پرونده",
"shareQrDialogTitle": "لینک اشتراک‌گذاری پرونده",
"shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.",
"copyShareLink": "کپی لینک",
"shareLinkCopied": "لینک در کلیپ‌بورد کپی شد",
"shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست"
},
"labCaseAccess": {
"pageTitle": "وظایف پرونده",
"loading": "در حال بارگذاری پرونده…",
"accessDeniedTitle": "دسترسی مجاز نیست",
"accessDenied": "اجازه مشاهده این پرونده را ندارید. با حساب لاب یا کلینیک دارای دسترسی وارد شوید و برای دسترسی کلینیک، ارائه‌دهنده همان درمان باشید.",
"emptyTasks": "وظیفه‌ای برای این پرونده یافت نشد.",
"caseSummary": "{clinic} · {patient}",
"labName": "لاب: {name}"
}, },
"tasks": { "tasks": {
"title": "وظایف", "title": "وظایف",
@@ -979,6 +994,7 @@
"PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.", "PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.",
"PERMISSION_EDIT_TASKS": "نمی‌توانید وظایف را ویرایش کنید.", "PERMISSION_EDIT_TASKS": "نمی‌توانید وظایف را ویرایش کنید.",
"PERMISSION_ACCESS_CASES": "به پرونده‌ها دسترسی ندارید.", "PERMISSION_ACCESS_CASES": "به پرونده‌ها دسترسی ندارید.",
"LAB_CASE_ACCESS_DENIED": "اجازه مشاهده این پرونده اشتراک‌گذاری‌شده را ندارید.",
"PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.", "PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.",
"PERMISSION_EDIT_STAFF": "نمی‌توانید ساعات کاری پرسنل را مدیریت کنید.", "PERMISSION_EDIT_STAFF": "نمی‌توانید ساعات کاری پرسنل را مدیریت کنید.",
"PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.", "PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.",

View File

@@ -467,7 +467,22 @@
"prevPage": "Vorige", "prevPage": "Vorige",
"nextPage": "Volgende", "nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
"statusLabel": "Status" "statusLabel": "Status",
"viewShareQr": "QR-code en deellink van dossier bekijken",
"shareQrDialogTitle": "Deellink dossier",
"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"
},
"labCaseAccess": {
"pageTitle": "Dossiertaken",
"loading": "Dossier laden…",
"accessDeniedTitle": "Geen toegang",
"accessDenied": "U hebt geen toestemming om dit dossier te bekijken. Meld u aan met een lab- of kliniekaccount met toegang en zorg dat u de behandelende zorgverlener bent voor kliniektoegang.",
"emptyTasks": "Geen taken gevonden voor dit dossier.",
"caseSummary": "{clinic} · {patient}",
"labName": "Lab: {name}"
}, },
"tasks": { "tasks": {
"title": "Taken", "title": "Taken",
@@ -978,6 +993,7 @@
"PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.", "PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.",
"PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.", "PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.",
"PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.", "PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.",
"LAB_CASE_ACCESS_DENIED": "U hebt geen toestemming om dit gedeelde dossier te bekijken.",
"PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.", "PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.",
"PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.", "PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.",
"PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.", "PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.",

View File

@@ -18,6 +18,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2", "recharts": "^3.9.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -5433,7 +5434,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -5844,7 +5844,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@@ -6210,7 +6209,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6515,7 +6513,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -6539,6 +6536,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"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/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6607,6 +6610,19 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"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": { "node_modules/react-redux": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",

View File

@@ -19,6 +19,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2", "recharts": "^3.9.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@@ -0,0 +1,10 @@
import { CaseTasksFocusView } from '@/components/ui/lab/CaseTasksFocusView';
interface LabCaseAccessPageProps {
params: Promise<{ token: string }>;
}
export default async function LabCaseAccessPage({ params }: LabCaseAccessPageProps) {
const { token } = await params;
return <CaseTasksFocusView token={token} />;
}

View File

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { usePathname, useRouter } from '@/i18n/navigation'; import { usePathname, useRouter } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
import Sidebar from '@/components/ui/shared/Sidebar'; import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
@@ -40,7 +41,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!isAuthReady) return; if (!isAuthReady) return;
if (!user) { if (!user) {
router.replace('/login'); if (pathname && pathname !== '/login') {
storeAuthRedirectFromPath(pathname);
router.replace(`/login?from=${encodeURIComponent(pathname)}`);
} else {
router.replace('/login');
}
return; return;
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react'; import { Suspense, useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -15,6 +16,10 @@ import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import {
consumeAuthRedirect,
storeAuthRedirectFromPath,
} from '@/lib/auth/postAuthRedirect';
type LoginForm = { type LoginForm = {
email: string; email: string;
@@ -22,13 +27,14 @@ type LoginForm = {
rememberMe: boolean; rememberMe: boolean;
}; };
export default function LoginPage() { function LoginPageContent() {
const t = useTranslations('auth'); const t = useTranslations('auth');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tValidation = useTranslations('validation'); const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors'); const tErrors = useTranslations('errors');
const { login, isLoading, user, isAuthReady } = useAuth(); const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savedEmail] = useState(() => getRememberedEmail()); const [savedEmail] = useState(() => getRememberedEmail());
@@ -43,10 +49,18 @@ export default function LoginPage() {
); );
useEffect(() => { useEffect(() => {
if (isAuthReady && user) { const from = searchParams.get('from');
router.push('/today'); if (from) {
storeAuthRedirectFromPath(from);
} }
}, [user, isAuthReady, router]); }, [searchParams]);
useEffect(() => {
if (!isAuthReady || !user || isLoading) return;
const orgReady = organizations.length <= 1 || currentOrganization;
if (!orgReady) return;
router.push(consumeAuthRedirect() ?? '/today');
}, [isAuthReady, user, isLoading, organizations, currentOrganization, router]);
const { const {
register, register,
@@ -152,3 +166,21 @@ export default function LoginPage() {
</AuthPageShell> </AuthPageShell>
); );
} }
function LoginPageFallback() {
const tCommon = useTranslations('common');
return (
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4">
<p className="text-text-secondary">{tCommon('loading')}</p>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginPageFallback />}>
<LoginPageContent />
</Suspense>
);
}

View File

@@ -10,6 +10,10 @@ import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
import {
LabCaseShareQrDialog,
LabCaseShareQrThumb,
} from '@/components/ui/lab/LabCaseShareQrDialog';
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { import {
@@ -89,6 +93,7 @@ export function CaseDetailPanel({
}: CaseDetailPanelProps) { }: CaseDetailPanelProps) {
const t = useTranslations('cases'); const t = useTranslations('cases');
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]); const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
useEffect(() => { useEffect(() => {
@@ -137,15 +142,7 @@ export function CaseDetailPanel({
</div> </div>
</div> </div>
<div className="flex w-full sm:w-auto shrink-0 flex-row sm:flex-col items-center sm:items-end justify-between sm:justify-start gap-2"> <div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
{showCommentsButton && onCommentsClick ? (
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
) : null}
{canEditImportant ? ( {canEditImportant ? (
<Checkbox <Checkbox
checked={labCase.isImportant ?? false} checked={labCase.isImportant ?? false}
@@ -155,21 +152,39 @@ export function CaseDetailPanel({
onChange={(checked) => onImportantChange?.(checked)} onChange={(checked) => onImportantChange?.(checked)}
/> />
) : null} ) : null}
{previewAttachment && labCase.attachments.length > 0 ? ( {showCommentsButton && onCommentsClick ? (
<button <Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
type="button" <MessageSquare className="h-4 w-4 me-1.5" />
onClick={() => setAttachmentsDialogOpen(true)} {commentCount > 0
className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" ? t('commentsCount', { count: commentCount })
title={previewAttachment.fileName} : t('showComments')}
aria-label={t('viewAttachments')} </Button>
> ) : null}
<LabCaseAttachmentPreview {labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
caseId={labCase.id} <div className="flex flex-row items-center gap-2 shrink-0">
attachment={previewAttachment} {previewAttachment && labCase.attachments.length > 0 ? (
loadBlob={loadAttachmentBlob} <button
className="h-full w-full" type="button"
/> onClick={() => setAttachmentsDialogOpen(true)}
</button> className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
title={previewAttachment.fileName}
aria-label={t('viewAttachments')}
>
<LabCaseAttachmentPreview
caseId={labCase.id}
attachment={previewAttachment}
loadBlob={loadAttachmentBlob}
className="h-full w-full"
/>
</button>
) : null}
{labCase.shareUrl ? (
<LabCaseShareQrThumb
shareUrl={labCase.shareUrl}
onClick={() => setShareQrDialogOpen(true)}
/>
) : null}
</div>
) : null} ) : null}
</div> </div>
</header> </header>
@@ -279,6 +294,14 @@ export function CaseDetailPanel({
attachments={labCase.attachments} attachments={labCase.attachments}
loadBlob={loadAttachmentBlob} loadBlob={loadAttachmentBlob}
/> />
{labCase.shareUrl ? (
<LabCaseShareQrDialog
open={shareQrDialogOpen}
onClose={() => setShareQrDialogOpen(false)}
shareUrl={labCase.shareUrl}
/>
) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,216 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
import { TaskRow } from '@/components/ui/lab/TaskRow';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
import { canEditLabTaskStatus } from '@/components/lab/labTaskStatusDisplay';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { asApiError } from '@/types/api';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { labCaseAccessApi, type LabCaseAccessSession } from '@/lib/api/lab-case-access';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { tasksApi } from '@/lib/api/tasks';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
interface CaseTasksFocusViewProps {
token: string;
}
export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) {
const t = useTranslations('labCaseAccess');
const tTasks = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { user, isAuthReady } = useAuth();
const { showError, showSuccess } = useToast();
const [session, setSession] = useState<LabCaseAccessSession | null>(null);
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
const [loading, setLoading] = useState(true);
const [accessDenied, setAccessDenied] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const locale = user?.language ?? 'en';
const canEditTasks = session?.canEditTaskStatus ?? false;
const canPostComments = session?.canPostComments ?? false;
const canToggleCommentVisibility = session?.canToggleCommentVisibility ?? false;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'IN_PROGRESS', label: tTasks('statusInProgress') },
{ value: 'COMPLETED', label: tTasks('statusCompleted') },
],
[tTasks],
);
const loadData = useCallback(async () => {
setLoading(true);
setAccessDenied(false);
try {
const [sessionRes, tasksRes] = await Promise.all([
labCaseAccessApi.resolve(token),
labCaseAccessApi.listTasks(token),
]);
setSession(sessionRes.data);
setTasks(tasksRes.data.items);
} catch (error: unknown) {
if (asApiError(error)?.code === 'LAB_CASE_ACCESS_DENIED') {
setAccessDenied(true);
} else {
showError(getUserFacingError(error, tErrors, t('accessDenied')));
}
setSession(null);
setTasks([]);
} finally {
setLoading(false);
}
}, [showError, t, tErrors, token]);
useEffect(() => {
if (!isAuthReady || !user) return;
void loadData();
}, [isAuthReady, user, loadData]);
useEffect(() => {
void prosthesisCatalogApi
.list()
.then((response) => setProsthesisCatalog(response.data))
.catch(() => {});
}, []);
const displayModel = useMemo(() => groupTasksForDisplay(tasks, 'date'), [tasks]);
const handleStatusUpdate = useCallback(
async (taskId: string, status: LabTaskStatus) => {
if (!canEditTasks || !user?.id) return;
const task = tasks.find((item) => item.id === taskId);
if (!task || !canEditLabTaskStatus(task, user.id, canEditTasks)) return;
setUpdatingTaskId(taskId);
try {
await tasksApi.updateStatus(taskId, status);
await loadData();
notifyTabBadgesChanged();
if (status === 'COMPLETED') {
showSuccess(tTasks('taskCompletedToast'));
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, tTasks('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}
},
[canEditTasks, loadData, showError, showSuccess, tErrors, tTasks, tasks, user?.id],
);
if (!isAuthReady || loading) {
return <p className="text-sm text-text-muted">{t('loading')}</p>;
}
if (accessDenied) {
return (
<div className="rounded-md border border-border bg-background-secondary/40 p-4 sm:p-6 text-center space-y-2 min-w-0">
<p className="text-sm font-medium text-text-primary">{t('accessDeniedTitle')}</p>
<p className="text-sm text-text-muted break-words">{t('accessDenied')}</p>
</div>
);
}
if (!session || displayModel.mode !== 'grouped' || displayModel.cases.length === 0) {
return <p className="text-sm text-text-muted">{t('emptyTasks')}</p>;
}
const caseGroup = displayModel.cases[0];
return (
<div className="space-y-4 min-w-0">
<header className="space-y-1 border-b border-border pb-3 min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
<p className="text-sm text-text-muted break-words">
{t('caseSummary', {
clinic: session.clinic.name,
patient: `${session.patient.firstName} ${session.patient.lastName}`.trim(),
})}
</p>
{session.lab ? (
<p className="text-sm text-text-muted break-words">
{t('labName', { name: session.lab.name })}
</p>
) : null}
</header>
<section className="surface-card min-h-[280px] min-w-0 overflow-x-hidden">
<section className="border-b border-border last:border-b-0">
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} />
{caseGroup.prosthesisGroups.map((prosthesisGroup) => (
<div key={prosthesisGroup.key} className="border-t border-border/50 first:border-t-0">
<TaskProsthesisGroupHeader
group={prosthesisGroup}
prosthesisCatalog={prosthesisCatalog}
/>
<ul>
{prosthesisGroup.tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
locale={locale}
flatMode={false}
canEdit={canEditTasks}
currentUserId={user?.id}
statusOptions={statusOptions}
updatingTaskId={updatingTaskId}
commentsOpen={false}
showCommentsButton={false}
prosthesisCatalog={prosthesisCatalog}
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
onToggleComments={() => {}}
onCommentError={showError}
/>
))}
</ul>
</div>
))}
</section>
</section>
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
<LabCaseCommentsPanel
caseId={session.labCaseId}
canPost={canPostComments}
canToggleVisibility={canToggleCommentVisibility}
loadComments={async () => {
const r = await labCaseAccessApi.listComments(token);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await labCaseAccessApi.addComment(token, {
body,
visibleToClinic,
});
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={
canToggleCommentVisibility
? async (commentId, visible) => {
const r = await labCaseAccessApi.setCommentVisibility(
token,
commentId,
visible,
);
return r.data;
}
: undefined
}
onError={showError}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import QRCode from 'react-qr-code';
interface LabCaseShareQrCodeProps {
value: string;
size?: number;
className?: string;
}
export function LabCaseShareQrCode({ value, size = 128, className }: LabCaseShareQrCodeProps) {
return (
<QRCode
value={value}
size={size}
level="M"
className={className}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Copy } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { Button } from '@/components/ui/shared/Button';
import { useToast } from '@/lib/hooks/useToast';
import { LabCaseShareQrCode } from '@/components/ui/lab/LabCaseShareQrCode';
interface LabCaseShareQrDialogProps {
open: boolean;
onClose: () => void;
shareUrl: string;
}
export function LabCaseShareQrDialog({ open, onClose, shareUrl }: LabCaseShareQrDialogProps) {
const t = useTranslations('cases');
const { showSuccess } = useToast();
const [copying, setCopying] = useState(false);
const handleCopy = useCallback(async () => {
setCopying(true);
try {
await navigator.clipboard.writeText(shareUrl);
showSuccess(t('shareLinkCopied'));
} catch {
// ignore clipboard failures
} finally {
setCopying(false);
}
}, [shareUrl, showSuccess, t]);
if (!open) return null;
return (
<ResponsiveDialogOverlay onBackdropClick={onClose}>
<ResponsiveDialogPanel
maxWidthClass="sm:max-w-3xl"
role="dialog"
aria-modal="true"
aria-labelledby="case-share-qr-title"
className="space-y-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 id="case-share-qr-title" className="text-lg font-semibold text-text-primary">
{t('shareQrDialogTitle')}
</h2>
<p className="text-sm text-text-muted mt-1">{t('shareQrDialogSubtitle')}</p>
</div>
<DialogCloseButton onClick={onClose} />
</div>
<div className="flex flex-col items-center gap-4 min-w-0">
<div className="w-full max-w-[17rem] sm:max-w-[16rem] rounded-md border border-border bg-white p-3 sm:p-4">
<LabCaseShareQrCode value={shareUrl} size={256} className="h-auto w-full" />
</div>
<p className="w-full min-w-0 break-all text-center text-sm text-text-secondary px-2">
{shareUrl}
</p>
<Button
type="button"
variant="outline"
disabled={copying}
className="w-full sm:w-auto"
onClick={() => void handleCopy()}
>
<Copy className="h-4 w-4 me-1.5" />
{t('copyShareLink')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
);
}
interface LabCaseShareQrThumbProps {
shareUrl: string;
onClick: () => void;
}
export function LabCaseShareQrThumb({ shareUrl, onClick }: LabCaseShareQrThumbProps) {
const t = useTranslations('cases');
return (
<button
type="button"
onClick={onClick}
className="aspect-square w-24 sm:w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden bg-white p-2 transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary flex items-center justify-center"
title={t('viewShareQr')}
aria-label={t('viewShareQr')}
>
<LabCaseShareQrCode value={shareUrl} size={96} className="h-full w-full max-h-full max-w-full" />
</button>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { Badge } from '@/components/ui/shared/Badge'; import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping'; import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
@@ -14,9 +15,18 @@ function formatPatientName(patient: { firstName: string; lastName: string }) {
interface TaskCaseGroupHeaderProps { interface TaskCaseGroupHeaderProps {
caseGroup: CaseTaskGroup; caseGroup: CaseTaskGroup;
locale: string; locale: string;
showCommentsButton?: boolean;
commentsOpen?: boolean;
onToggleComments?: () => void;
} }
export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) { export function TaskCaseGroupHeader({
caseGroup,
locale,
showCommentsButton = false,
commentsOpen = false,
onToggleComments,
}: TaskCaseGroupHeaderProps) {
const t = useTranslations('tasks'); const t = useTranslations('tasks');
const progress = countCaseTaskProgress(caseGroup); const progress = countCaseTaskProgress(caseGroup);
@@ -26,7 +36,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
return ( return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-border/80 bg-background-secondary/95 px-3 py-2.5 backdrop-blur-sm supports-[backdrop-filter]:bg-background-secondary/80"> <div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-border/80 bg-background-secondary/95 px-3 py-2.5 backdrop-blur-sm supports-[backdrop-filter]:bg-background-secondary/80">
<div className="min-w-0 space-y-0.5"> <div className="min-w-0 space-y-0.5 flex-1">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<p className="text-sm font-semibold text-text-primary truncate"> <p className="text-sm font-semibold text-text-primary truncate">
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '} {t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
@@ -49,9 +59,26 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
</p> </p>
) : null} ) : null}
</div> </div>
<p className="text-xs text-text-muted tabular-nums shrink-0"> <div className="flex items-center gap-2 shrink-0">
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })} {showCommentsButton && onToggleComments ? (
</p> <button
type="button"
onClick={onToggleComments}
className={`inline-flex h-9 w-9 items-center justify-center rounded-md border ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
aria-label={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<p className="text-xs text-text-muted tabular-nums">
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })}
</p>
</div>
</div> </div>
); );
} }

View File

@@ -33,6 +33,7 @@ interface TaskRowProps {
statusOptions: { value: LabTaskStatus; label: string }[]; statusOptions: { value: LabTaskStatus; label: string }[];
updatingTaskId: string | null; updatingTaskId: string | null;
commentsOpen: boolean; commentsOpen: boolean;
showCommentsButton?: boolean;
prosthesisCatalog: readonly ProsthesisCatalogEntry[]; prosthesisCatalog: readonly ProsthesisCatalogEntry[];
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
onToggleComments: (taskId: string) => void; onToggleComments: (taskId: string) => void;
@@ -55,6 +56,7 @@ export function TaskRow({
statusOptions, statusOptions,
updatingTaskId, updatingTaskId,
commentsOpen, commentsOpen,
showCommentsButton = true,
prosthesisCatalog, prosthesisCatalog,
onStatusUpdate, onStatusUpdate,
onToggleComments, onToggleComments,
@@ -78,7 +80,7 @@ export function TaskRow({
.join(' '); .join(' ');
const commentsButton = const commentsButton =
canEdit && !exiting ? ( showCommentsButton && canEdit && !exiting ? (
<button <button
type="button" type="button"
onClick={() => onToggleComments(task.id)} onClick={() => onToggleComments(task.id)}

View File

@@ -10,6 +10,7 @@ import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader'; import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader'; import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
import { TaskRow } from '@/components/ui/lab/TaskRow'; import { TaskRow } from '@/components/ui/lab/TaskRow';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping'; import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
import { import {
buildDefaultLocateParams, buildDefaultLocateParams,
@@ -64,6 +65,7 @@ export function TasksPage() {
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null); const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [exitingTaskIds, setExitingTaskIds] = useState<Set<string>>(() => new Set()); const [exitingTaskIds, setExitingTaskIds] = useState<Set<string>>(() => new Set());
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null); const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
const [search, setSearch] = useState(DEFAULT_TASKS_VIEW.search); const [search, setSearch] = useState(DEFAULT_TASKS_VIEW.search);
const [clinicId, setClinicId] = useState(DEFAULT_TASKS_VIEW.clinicId); const [clinicId, setClinicId] = useState(DEFAULT_TASKS_VIEW.clinicId);
@@ -255,6 +257,7 @@ export function TasksPage() {
setSortBy(DEFAULT_TASKS_VIEW.sortBy); setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir); setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setExpandedCommentsTaskId(null); setExpandedCommentsTaskId(null);
setExpandedCommentsCaseId(null);
setHighlightTaskId(task.id); setHighlightTaskId(task.id);
try { try {
@@ -357,7 +360,8 @@ export function TasksPage() {
currentUserId={user?.id} currentUserId={user?.id}
statusOptions={statusOptions} statusOptions={statusOptions}
updatingTaskId={updatingTaskId} updatingTaskId={updatingTaskId}
commentsOpen={expandedCommentsTaskId === task.id} commentsOpen={flatMode && expandedCommentsTaskId === task.id}
showCommentsButton={flatMode}
prosthesisCatalog={prosthesisCatalog} prosthesisCatalog={prosthesisCatalog}
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)} onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
onToggleComments={(id) => onToggleComments={(id) =>
@@ -519,7 +523,43 @@ export function TasksPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{displayModel.cases.map((caseGroup) => ( {displayModel.cases.map((caseGroup) => (
<section key={caseGroup.labCaseId} className="border-b border-border last:border-b-0"> <section key={caseGroup.labCaseId} className="border-b border-border last:border-b-0">
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} /> <TaskCaseGroupHeader
caseGroup={caseGroup}
locale={locale}
showCommentsButton={canEdit}
commentsOpen={expandedCommentsCaseId === caseGroup.labCaseId}
onToggleComments={() =>
setExpandedCommentsCaseId((prev) =>
prev === caseGroup.labCaseId ? null : caseGroup.labCaseId,
)
}
/>
{expandedCommentsCaseId === caseGroup.labCaseId && canEdit ? (
<div className="border-b border-border/50 px-3 pb-3">
<LabCaseCommentsPanel
caseId={caseGroup.labCaseId}
canPost
canToggleVisibility
loadComments={async () => {
const r = await tasksApi.listComments(caseGroup.labCaseId);
return r.data;
}}
onPost={async (body, visibleToClinic) => {
const r = await tasksApi.addComment(caseGroup.labCaseId, {
body,
visibleToClinic,
});
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {
const r = await tasksApi.setCommentVisibility(commentId, visible);
return r.data;
}}
onError={showError}
/>
</div>
) : null}
{caseGroup.prosthesisGroups.map((prosthesisGroup) => ( {caseGroup.prosthesisGroups.map((prosthesisGroup) => (
<div <div
key={prosthesisGroup.key} key={prosthesisGroup.key}

View File

@@ -0,0 +1,75 @@
import { apiClient } from './client';
import type { LabCaseComment, LabTaskListItem } from '@/types/cases';
export interface LabCaseAccessProsthesisGroup {
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
teeth: string[];
}
export interface LabCaseAccessSession {
labCaseId: string;
accessMode: 'lab' | 'clinic';
canEditTaskStatus: boolean;
canPostComments: boolean;
canToggleCommentVisibility: boolean;
shareUrl: string;
sentAt: string | null;
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
clinic: { id: string; name: string };
lab: { id: string; name: string } | null;
patient: { id: string; firstName: string; lastName: string };
prosthesisGroups: LabCaseAccessProsthesisGroup[];
}
export const labCaseAccessApi = {
resolve: async (
token: string,
): Promise<{ success: boolean; data: LabCaseAccessSession }> => {
const response = await apiClient.get(`/lab-cases/access/${encodeURIComponent(token)}`);
return response.data;
},
listTasks: async (
token: string,
): Promise<{ success: boolean; data: { items: LabTaskListItem[] } }> => {
const response = await apiClient.get(
`/lab-cases/access/${encodeURIComponent(token)}/tasks`,
);
return response.data;
},
listComments: async (
token: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(
`/lab-cases/access/${encodeURIComponent(token)}/comments`,
);
return response.data;
},
addComment: async (
token: string,
payload: { body: string; visibleToClinic?: boolean },
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(
`/lab-cases/access/${encodeURIComponent(token)}/comments`,
payload,
);
return response.data;
},
setCommentVisibility: async (
token: string,
commentId: string,
visibleToClinic: boolean,
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.patch(
`/lab-cases/access/${encodeURIComponent(token)}/comments/${commentId}/visibility`,
{ visibleToClinic },
);
return response.data;
},
};

View File

@@ -0,0 +1,17 @@
import { stripLocaleFromPathname } from '@/i18n/routing';
const AUTH_REDIRECT_KEY = 'authRedirect';
export function storeAuthRedirectFromPath(pathWithOptionalLocale: string) {
if (typeof window === 'undefined') return;
const path = stripLocaleFromPathname(pathWithOptionalLocale);
if (!path || path === '/login' || path === '/register') return;
sessionStorage.setItem(AUTH_REDIRECT_KEY, path);
}
export function consumeAuthRedirect(): string | null {
if (typeof window === 'undefined') return null;
const path = sessionStorage.getItem(AUTH_REDIRECT_KEY);
if (path) sessionStorage.removeItem(AUTH_REDIRECT_KEY);
return path;
}

View File

@@ -17,6 +17,7 @@ import {
startProactiveSessionRefresh, startProactiveSessionRefresh,
} from '@/lib/auth/proactiveRefresh'; } from '@/lib/auth/proactiveRefresh';
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api'; import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
function toApiError(err: unknown): ApiError { function toApiError(err: unknown): ApiError {
return ( return (
@@ -288,10 +289,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authApi.selectOrganization(org.id); await authApi.selectOrganization(org.id);
setCurrentOrganization(org); setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id); localStorage.setItem('currentOrganizationId', org.id);
router.push('/today');
} else { } else {
router.push('/select-organization'); router.push('/select-organization');
} }
} catch (err: any) { } catch (err: any) {
@@ -332,7 +331,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authApi.selectOrganization(org.id); await authApi.selectOrganization(org.id);
setCurrentOrganization(org); setCurrentOrganization(org);
localStorage.setItem('currentOrganizationId', org.id); localStorage.setItem('currentOrganizationId', org.id);
router.push('/today');
} else { } else {
router.push('/select-organization'); router.push('/select-organization');
} }
@@ -388,12 +386,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
plan: (organization as { plan?: Organization['plan'] }).plan, plan: (organization as { plan?: Organization['plan'] }).plan,
}); });
const redirectPath = const redirectPath = consumeAuthRedirect();
typeof window !== 'undefined'
? sessionStorage.getItem('authRedirect')
: null;
if (redirectPath) { if (redirectPath) {
sessionStorage.removeItem('authRedirect');
router.push(redirectPath); router.push(redirectPath);
} else { } else {
router.push('/today'); router.push('/today');

View File

@@ -119,6 +119,7 @@ export interface LabCaseDetail {
tasks: LabCaseTask[]; tasks: LabCaseTask[];
tasksByTooth: LabCaseTaskGroup[]; tasksByTooth: LabCaseTaskGroup[];
taskProgress: { completed: number; total: number }; taskProgress: { completed: number; total: number };
shareUrl?: string | null;
} }
export interface ListLabCasesParams { export interface ListLabCasesParams {