Compare commits
12 Commits
improvemen
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f6eacd0fb | |||
| 52c4480c5d | |||
| 1ca8e6178e | |||
| 6c7e0574b9 | |||
| feafc3a219 | |||
| 8fa856a980 | |||
| 005e0b0394 | |||
| c1846f8e22 | |||
| 663eafa3e8 | |||
| 5cd3436d0c | |||
| 5d3597f973 | |||
| 4d7a4b390f |
@@ -9,4 +9,5 @@ alwaysApply: false
|
||||
- **Brand** (wordmark + logo): `frontend/src/assets/brand/*.svg`. Inline in `BrandWordmark` / `BrandLogo` / `BrandLockup` so fills can use `currentColor`. After editing an SVG, run `frontend/scripts/build-brand-components.mjs`.
|
||||
- **Favicon:** `frontend/src/app/icon.svg` (hex fills — `currentColor` does not work in tab icons).
|
||||
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
|
||||
- **`public/`** is only for files that must be fetched by URL (e.g. og images). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
|
||||
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `<img>` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
|
||||
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
---
|
||||
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
|
||||
globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.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,backend/src/modules/treatments/treatments.service.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Lab case share link
|
||||
|
||||
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent.
|
||||
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on lab case detail **and** clinic treatment lab-case drafts 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.
|
||||
- **Treatment dispatch:** same thumb + dialog on sent cases (`LabCasesDispatchPanel`). Dest/jobs share a row with the QR; tracker + comments are full width below.
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Lab Cases tab — prosthesis filter, auto-select, list cards, Case Sheet PDF
|
||||
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/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,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/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,frontend/src/components/treatment/prosthesisJobPath.ts,backend/src/modules/cases/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
@@ -8,7 +8,7 @@ alwaysApply: false
|
||||
|
||||
- **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type.
|
||||
- **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. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key.
|
||||
- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key. Job titles are the picker path to the **leaf** (`prosthesisJobPath.ts` / `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, never the category alone. Indirect omits Veneer/Inlay/Onlay/Overlay (Inlay · Layered ceramic). Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap does not collapse between inline spans.
|
||||
- **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`.
|
||||
- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
|
||||
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
|
||||
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/treatment/prosthesisJobPath.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
@@ -8,7 +8,7 @@ alwaysApply: false
|
||||
|
||||
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
|
||||
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. Unconnected teeth stay one pipeline; **connected** teeth that share a type share a pipeline; `partial_denture` is one pipeline for all selected teeth.
|
||||
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type).
|
||||
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type). Titles are the picker path to the leaf (`prosthesisJobPath.ts`), not the category name alone. Indirect omits the Veneer/Inlay/Onlay/Overlay parent. Path joins use `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
|
||||
- **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}”.
|
||||
- **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments.
|
||||
|
||||
@@ -8,10 +8,10 @@ alwaysApply: false
|
||||
|
||||
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
||||
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
|
||||
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail. Category parent is the heaviest family tone, children lighter. Lab dispatch is primary, Chart is outline, same width. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
|
||||
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical `border-e` divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail sized to the **first child card**; parent-bar back arrow is black (not family tint). Category parent is the heaviest family tone, children lighter. **Add detail**, Lab dispatch, and Chart share the same control width (`WorkspaceActionLabel`). Lab dispatch is primary, Chart is outline. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
|
||||
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
|
||||
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
|
||||
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments).
|
||||
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments). Sent cases: dest/jobs share a row with the Cases QR thumb; tracker + comments are **full width below** the QR.
|
||||
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
|
||||
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
|
||||
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
|
||||
|
||||
@@ -11,8 +11,8 @@ Shipped cases get a stable **access token** and share URL. QR + link open a focu
|
||||
|
||||
- **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`).
|
||||
- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`; clinic drafts use `TreatmentsService.ensureSentLabCaseAccessTokens()` on getDraft.
|
||||
- **URL:** `buildLabCaseShareUrl(token, locale)` → `{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). Clinic lab-case payloads include the same `shareUrl`.
|
||||
|
||||
## Backend API (`LabCaseAccessController`)
|
||||
|
||||
@@ -41,11 +41,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|
||||
|-------|------|
|
||||
| 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 UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` **and** Treatment `LabCasesDispatchPanel` |
|
||||
| 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).
|
||||
|
||||
**Treatment lab dispatch:** after send, the same thumb + dialog (same `shareUrl` / token) so the dentist can scan to the focus page. Dest/jobs share a row with the QR; tracker + comments span the card **below** it (not beside it).
|
||||
|
||||
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
|
||||
|
||||
## Auth redirect (logged out → login → back)
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
|
||||
## List behavior
|
||||
|
||||
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
|
||||
- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers.
|
||||
- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers. Titles use the picker path to the **leaf** (`prosthesisJobPath.ts` via `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, not “Crowns” alone. Indirect jobs skip the long parent title: Inlay · Layered ceramic, not Veneer/Inlay/Onlay/Overlay · Inlay · …. Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
|
||||
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
|
||||
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
|
||||
- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (connected same-type teeth share a row; otherwise tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
|
||||
@@ -21,7 +21,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
|
||||
|
||||
List `where`: received clinic cases (`sends` + `sentAt`) **OR** `origin = LAB_INTERNAL` and `destinationOrganizationId = lab`. Map clinic/patient from `treatment` **or** snapshots / partner org. Search includes snapshot names.
|
||||
|
||||
Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass.
|
||||
Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass. Clinic Treatment **Lab dispatch** shows the same QR after send (see lab-case-share-link skill).
|
||||
|
||||
## Filters (`GET /cases`)
|
||||
|
||||
@@ -35,7 +35,7 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di
|
||||
|
||||
## List card UI
|
||||
|
||||
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge.
|
||||
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge. Type text is the picker path to the leaf (`prosthesisJobPath.ts`).
|
||||
|
||||
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. Unconnected teeth stay one group each (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** teeth that share a type are one group whose `teeth` lists the bridge. **Partial denture** is one group whose `teeth` is every selected unit.
|
||||
|
||||
@@ -63,7 +63,7 @@ Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not*
|
||||
| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture |
|
||||
| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF |
|
||||
| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture |
|
||||
| Prosthesis labels from **catalog** (then task label) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
|
||||
| Prosthesis labels from **`prosthesisJobPathLabel`** (category · … · leaf) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
|
||||
|
||||
### Layout conventions
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
||||
|
||||
- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code).
|
||||
- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeColorFromCatalog(code, catalog)` as **text color** (no type pills), **not** list row index.
|
||||
- Job titles: picker path to the last node ([`prosthesisJobPath.ts`](frontend/src/components/treatment/prosthesisJobPath.ts) / `ProsthesisStackedTypeLabel`) so technicians see e.g. Implants · Screw Retained, not “Implants”. Indirect skips the long parent: Inlay · Layered ceramic. Join with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap around · does not collapse.
|
||||
- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard.
|
||||
|
||||
## Filters
|
||||
|
||||
@@ -41,9 +41,9 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
|
||||
| Stage | UI | When |
|
||||
|-------|-----|------|
|
||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
|
||||
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
|
||||
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
|
||||
|
||||
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use that same wrapping grid with a parent-colored **L** rail (not a per-card tree). Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
|
||||
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
|
||||
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
|
||||
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
|
||||
@@ -132,7 +132,7 @@ Saved lab-dependent detail with **no FDI teeth and no arch jobs** (`UA`/`LA`) ca
|
||||
Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.
|
||||
|
||||
- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
|
||||
- Sent / in progress: live composer until tasks complete (`canPostComments`).
|
||||
- Sent / in progress: live composer until tasks complete (`canPostComments`). Same-card share QR sits beside dest/jobs only; tracker + comments stay full width below (`LabCasesDispatchPanel`).
|
||||
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
|
||||
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
||||
|
||||
|
||||
@@ -44,8 +44,6 @@ defaults:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: windows
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout (this Gitea)
|
||||
run: |
|
||||
@@ -75,6 +73,7 @@ jobs:
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Resolve image tag (v*.*.* only)
|
||||
id: meta
|
||||
@@ -90,10 +89,7 @@ jobs:
|
||||
Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)"
|
||||
exit 1
|
||||
}
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$tag`n", $utf8)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
|
||||
Write-Host "image_tag=$tag REGISTRY_PREFIX=$prefix"
|
||||
|
||||
- name: Log in to container registry
|
||||
@@ -103,13 +99,43 @@ jobs:
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Resolve node:20-alpine (Gitea, then mirrors, Hub last)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
$extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim()
|
||||
if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' }
|
||||
# Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument.
|
||||
$scriptArgs = @{ RegistryPrefix = $prefix }
|
||||
if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra }
|
||||
& .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Build and push backend (tag only, not :latest)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
docker build -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ./backend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
|
||||
Write-Host "Missing .ci-node-image"
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
|
||||
Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)"
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "docker build attempt $i/3"
|
||||
docker build --build-arg "NODE_IMAGE=$nodeImage" -t "$prefix/dyolink-backend:$tag" ./backend
|
||||
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
|
||||
}
|
||||
if (-not $ok) { exit 1 }
|
||||
docker push "$prefix/dyolink-backend:$tag"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Build and push frontend (nudentic.ir baked in)
|
||||
env:
|
||||
@@ -117,19 +143,37 @@ jobs:
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
|
||||
Write-Host "Missing .ci-node-image"
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
|
||||
$base = $env:PROD_PUBLIC_BASE_URL.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' }
|
||||
$base = $base.TrimEnd('/')
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||
./frontend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||
Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "docker build attempt $i/3"
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
||||
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||
-t "$prefix/dyolink-frontend:$tag" `
|
||||
./frontend
|
||||
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
|
||||
}
|
||||
if (-not $ok) { exit 1 }
|
||||
docker push "$prefix/dyolink-frontend:$tag"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
@@ -163,6 +207,7 @@ jobs:
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
@@ -193,19 +238,25 @@ jobs:
|
||||
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
|
||||
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
|
||||
ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/docker-compose.prod.yml `
|
||||
"${user}@${hostName}:${infra}/docker-compose.prod.yml"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/scripts/prod-remote-deploy.sh `
|
||||
"${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/scripts/render-nginx-ssl.sh `
|
||||
"${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/nginx/nginx.ssl.conf.template `
|
||||
"${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Login on Linux and deploy tag
|
||||
run: |
|
||||
@@ -218,7 +269,8 @@ jobs:
|
||||
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
|
||||
$regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' }
|
||||
$tag = "${{ needs.build-and-push.outputs.image_tag }}"
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
|
||||
$pass = @'
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
@@ -226,3 +278,4 @@ jobs:
|
||||
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
|
||||
$remote = "docker login $regHost -u $regUser --password-stdin && PROD_INFRA_DIR=$infra $infra/scripts/prod-remote-deploy.sh $tag"
|
||||
$pass.Trim() | ssh.exe @ssh -p $port "${user}@${hostName}" $remote
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
# STAGING_HTTP_PORT public HTTP port (default 80) — Windows portproxy listens here → 18088
|
||||
# STAGING_LOCAL_PORT Docker bind on 127.0.0.1 (default 18088) — must not equal the public port if portproxy owns it
|
||||
# CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000
|
||||
# NODE_IMAGE_SOURCE extra base-image ref(s), comma-separated, tried before built-in mirrors
|
||||
# e.g. docker.arvancloud.ir/library/node:20-alpine
|
||||
#
|
||||
# Same Windows PC runs Gitea + runner + deploy:
|
||||
# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host)
|
||||
@@ -33,6 +35,7 @@
|
||||
# Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"]
|
||||
#
|
||||
# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner).
|
||||
# Windows PowerShell 5.1 does not fail a step when docker/git return non-zero — always check $LASTEXITCODE.
|
||||
|
||||
name: Registry — build, push, deploy
|
||||
|
||||
@@ -48,8 +51,6 @@ defaults:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: windows
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout (clone from this Gitea — no gitea.com)
|
||||
run: |
|
||||
@@ -74,16 +75,7 @@ jobs:
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
|
||||
- name: Image tag and registry prefix
|
||||
id: meta
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$short = (git rev-parse --short HEAD).Trim()
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Log in to container registry
|
||||
run: |
|
||||
@@ -92,17 +84,52 @@ jobs:
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Resolve node:20-alpine (Gitea, then mirrors, Hub last)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
$extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim()
|
||||
if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' }
|
||||
# Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument.
|
||||
$scriptArgs = @{ RegistryPrefix = $prefix }
|
||||
if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra }
|
||||
& .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
docker build `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
|
||||
./backend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
|
||||
$tag = "${{ github.sha }}".Substring(0, 7)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($tag) -or [string]::IsNullOrWhiteSpace($prefix)) {
|
||||
Write-Host "Missing github.sha, REGISTRY_HOST, or REGISTRY_OWNER"
|
||||
exit 1
|
||||
}
|
||||
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
|
||||
Write-Host "Missing .ci-node-image"
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
|
||||
Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)"
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "docker build attempt $i/3"
|
||||
docker build `
|
||||
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||
-t "$prefix/dyolink-backend:$tag" `
|
||||
-t "$prefix/dyolink-backend:latest" `
|
||||
./backend
|
||||
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
|
||||
}
|
||||
if (-not $ok) { exit 1 }
|
||||
docker push "$prefix/dyolink-backend:$tag"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
docker push "$prefix/dyolink-backend:latest"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Build and push frontend
|
||||
env:
|
||||
@@ -110,19 +137,37 @@ jobs:
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
$tag = "${{ github.sha }}".Substring(0, 7)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
$base = $env:PUBLIC_BASE_URL
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
||||
./frontend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
|
||||
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
|
||||
Write-Host "Missing .ci-node-image"
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
|
||||
Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Write-Host "docker build attempt $i/3"
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
||||
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||
-t "$prefix/dyolink-frontend:$tag" `
|
||||
-t "$prefix/dyolink-frontend:latest" `
|
||||
./frontend
|
||||
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
|
||||
}
|
||||
if (-not $ok) { exit 1 }
|
||||
docker push "$prefix/dyolink-frontend:$tag"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
docker push "$prefix/dyolink-frontend:latest"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
@@ -151,6 +196,7 @@ jobs:
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Write deploy.registry.env and validate secrets path
|
||||
run: |
|
||||
@@ -173,16 +219,19 @@ jobs:
|
||||
if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' }
|
||||
$localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' }
|
||||
$imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
|
||||
$imageTag = "${{ github.sha }}".Substring(0, 7)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||
Write-Host "IMAGE_TAG=$imageTag REGISTRY_PREFIX=$prefix"
|
||||
$lines = @(
|
||||
"REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
|
||||
"REGISTRY_PREFIX=$prefix",
|
||||
"IMAGE_TAG=$imageTag",
|
||||
"STAGING_HTTP_PORT=$stagingPort",
|
||||
"STAGING_LOCAL_PORT=$localPort",
|
||||
"DEPLOY_SECRETS_DIR=$SD"
|
||||
)
|
||||
Set-Location infrastructure
|
||||
$lines | Set-Content -Path deploy.registry.env -Encoding utf8
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText((Join-Path (Get-Location) 'deploy.registry.env'), ($lines -join "`n") + "`n", $utf8)
|
||||
|
||||
- name: Log in to container registry (for pull)
|
||||
run: |
|
||||
@@ -191,10 +240,13 @@ jobs:
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Pull and start stack
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location infrastructure
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
10
AGENTS.md
@@ -47,9 +47,9 @@ frontend/src/
|
||||
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline, same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments.
|
||||
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline; **Add detail** uses the same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; expanded children use a parent-colored **L** rail sized to the first child card; parent-bar back arrow is black; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments.
|
||||
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; midline 11–21 / 41–31 allowed. Never a 1-tooth connected. On the non-prosthesis chart, Shift+range selects as singles. **Prosthesis assign chart (Exocad-style):** plain click opens the picker (tooth is not selected until a type is picked); **Done** stores that tooth’s jobs as the copy brush; **Ctrl/Cmd+click** or drag copies those jobs onto one tooth; **Shift+click** copies onto the inclusive same-arch range as **singles** (connect with neighbor circles if it is a bridge). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth.
|
||||
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth).
|
||||
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth). Sent cases: dest/jobs share a row with the Cases **QR** thumb; tracker + comments are full width below.
|
||||
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
|
||||
|
||||
**Treatment lab rules (quick ref):**
|
||||
@@ -64,14 +64,14 @@ frontend/src/
|
||||
|
||||
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
|
||||
|
||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||
|
||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||
|
||||
**Lab case share link (quick ref):**
|
||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
|
||||
- **Clinic:** treatment **provider** only — read-only tasks, can comment.
|
||||
- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
|
||||
- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
|
||||
# ============================================
|
||||
# STAGE 1: BUILDER STAGE
|
||||
# ============================================
|
||||
FROM node:20-alpine AS builder
|
||||
FROM ${NODE_IMAGE} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -30,7 +33,7 @@ RUN npm prune --omit=dev
|
||||
# ============================================
|
||||
# STAGE 2: PRODUCTION STAGE
|
||||
# ============================================
|
||||
FROM node:20-alpine
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export const ErrorCode = {
|
||||
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
|
||||
|
||||
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
|
||||
PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE',
|
||||
|
||||
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
||||
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
||||
@@ -110,6 +111,8 @@ export const ErrorCode = {
|
||||
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
|
||||
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
|
||||
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
|
||||
STAFF_CANNOT_CLEAR_OWN_PASSWORD: 'STAFF_CANNOT_CLEAR_OWN_PASSWORD',
|
||||
STAFF_PASSWORD_CLEAR_ACTIVE_ONLY: 'STAFF_PASSWORD_CLEAR_ACTIVE_ONLY',
|
||||
|
||||
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
|
||||
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
|
||||
|
||||
@@ -341,12 +341,16 @@ export class AppointmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true, isWalkIn: true },
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: {
|
||||
id: patientId,
|
||||
isWalkIn: false,
|
||||
createdByOrganizationId: organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient || patient.isWalkIn) {
|
||||
if (!patient) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,16 +25,17 @@ export class PatientsController {
|
||||
constructor(private readonly patientsService: PatientsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create or return existing global patient by mobile' })
|
||||
@ApiOperation({ summary: 'Create or return this clinic’s patient by mobile' })
|
||||
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.create(createPatientDto, organizationId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Search all patients globally' })
|
||||
findAll(@Query() query: ListPatientsDto) {
|
||||
return this.patientsService.findAll(query);
|
||||
@ApiOperation({ summary: 'Search patients created by the current clinic' })
|
||||
findAll(@Query() query: ListPatientsDto, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findAll(query, organizationId);
|
||||
}
|
||||
|
||||
@Get(':id/appointments')
|
||||
@@ -48,14 +49,20 @@ export class PatientsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one patient by id' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.patientsService.findOne(id);
|
||||
@ApiOperation({ summary: 'Get one patient created by the current clinic' })
|
||||
findOne(@Param('id') id: string, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findOne(id, organizationId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update global patient record' })
|
||||
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
|
||||
return this.patientsService.update(id, updatePatientDto);
|
||||
@ApiOperation({ summary: 'Update a patient created by the current clinic' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updatePatientDto: UpdatePatientDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ export class PatientsService {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { success: true, data: existing, existing: true as const };
|
||||
if (
|
||||
!existing.isWalkIn &&
|
||||
existing.createdByOrganizationId === organizationId
|
||||
) {
|
||||
return { success: true, data: existing, existing: true as const };
|
||||
}
|
||||
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const patient = await this.prisma.patient.create({
|
||||
@@ -39,12 +45,13 @@ export class PatientsService {
|
||||
return { success: true, data: patient, existing: false as const };
|
||||
}
|
||||
|
||||
async findAll(query: ListPatientsDto) {
|
||||
async findAll(query: ListPatientsDto, organizationId: string) {
|
||||
const { page = 1, limit = 10, q } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
isWalkIn: false,
|
||||
createdByOrganizationId: organizationId,
|
||||
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
|
||||
};
|
||||
|
||||
@@ -72,27 +79,13 @@ export class PatientsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!patient || patient.isWalkIn) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
async findOne(id: string, organizationId: string) {
|
||||
const patient = await this.findNamedPatientInOrg(id, organizationId);
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
async update(id: string, updatePatientDto: UpdatePatientDto) {
|
||||
await this.ensurePatient(id);
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
select: { isWalkIn: true },
|
||||
});
|
||||
if (patient?.isWalkIn) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
|
||||
await this.findNamedPatientInOrg(id, organizationId);
|
||||
|
||||
const data: {
|
||||
firstName?: string;
|
||||
@@ -110,7 +103,15 @@ export class PatientsService {
|
||||
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
|
||||
}
|
||||
if (updatePatientDto.mobile !== undefined) {
|
||||
data.mobile = this.resolveMobile(updatePatientDto.mobile);
|
||||
const mobile = this.resolveMobile(updatePatientDto.mobile);
|
||||
const taken = await this.prisma.patient.findUnique({
|
||||
where: { mobile },
|
||||
select: { id: true, createdByOrganizationId: true, isWalkIn: true },
|
||||
});
|
||||
if (taken && taken.id !== id) {
|
||||
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
data.mobile = mobile;
|
||||
}
|
||||
if (updatePatientDto.email !== undefined) {
|
||||
data.email = updatePatientDto.email?.trim() || null;
|
||||
@@ -138,7 +139,7 @@ export class PatientsService {
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanViewPatients(actorUserId, organizationId);
|
||||
await this.ensurePatient(patientId);
|
||||
await this.findNamedPatientInOrg(patientId, organizationId);
|
||||
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
where: { organizationId, patientId },
|
||||
@@ -241,14 +242,18 @@ export class PatientsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
private async findNamedPatientInOrg(id: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isWalkIn: false,
|
||||
createdByOrganizationId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return patient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,20 @@ export class StaffController {
|
||||
return this.staffService.invite(req.user.id, organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('members/:membershipId/clear-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Clear a staff member password and return a setup link (owner or TAB_STAFF_EDIT; active members only)',
|
||||
})
|
||||
clearPassword(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('membershipId') membershipId: string,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffService.clearPassword(req.user.id, organizationId, membershipId);
|
||||
}
|
||||
|
||||
@Post('members/:membershipId/invitation-link')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -49,7 +49,7 @@ export class StaffService {
|
||||
this.prisma.membership.findMany({
|
||||
where: { organizationId },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
user: { select: { id: true, email: true, name: true, passwordHash: true } },
|
||||
permissions: { include: { permission: true } },
|
||||
invitations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -80,6 +80,7 @@ export class StaffService {
|
||||
isOwner: m.isOwner,
|
||||
isActive: m.isOwner ? true : m.isActive,
|
||||
invitationStatus: this.getInvitationStatus(m),
|
||||
hasPassword: Boolean(m.user.passwordHash),
|
||||
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
|
||||
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
|
||||
permissions: m.isOwner
|
||||
@@ -310,6 +311,77 @@ export class StaffService {
|
||||
organizationName: org.name,
|
||||
expiresAt: invitation.expiresAt.toISOString(),
|
||||
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
|
||||
mode: invitation.membership.isActive ? 'password_setup' : 'join',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async clearPassword(
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
membershipId: string,
|
||||
) {
|
||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { id: membershipId, organizationId },
|
||||
include: {
|
||||
user: { select: { id: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (membership.isOwner) {
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (membership.userId === actorUserId) {
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!membership.isActive) {
|
||||
throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const plainToken = this.generateInviteToken();
|
||||
const tokenHash = this.hashInviteToken(plainToken);
|
||||
|
||||
const invitation = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: membership.userId },
|
||||
data: { passwordHash: null },
|
||||
});
|
||||
await tx.session.deleteMany({
|
||||
where: { userId: membership.userId },
|
||||
});
|
||||
await tx.staffInvitation.updateMany({
|
||||
where: {
|
||||
membershipId: membership.id,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return tx.staffInvitation.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
invitedById: actorUserId,
|
||||
tokenHash,
|
||||
expiresAt: this.getInviteExpiryDate(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
invitationId: invitation.id,
|
||||
email: membership.user.email,
|
||||
invitationUrl: this.buildInviteUrl(plainToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,13 +115,14 @@ export class TreatmentsController {
|
||||
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
|
||||
getDraft(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.getDraftForAppointment(
|
||||
appointmentId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ export class TreatmentsController {
|
||||
saveDraft(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Body() dto: SaveTreatmentDraftDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveDraftForAppointment(
|
||||
@@ -138,6 +139,7 @@ export class TreatmentsController {
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ export class TreatmentsController {
|
||||
saveLabCases(
|
||||
@Param('appointmentId') appointmentId: string,
|
||||
@Body() dto: SaveTreatmentLabCasesDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveLabCasesForAppointment(
|
||||
@@ -154,6 +156,7 @@ export class TreatmentsController {
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,13 +304,14 @@ export class TreatmentsController {
|
||||
@ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' })
|
||||
getDraftByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.getDraftForTreatment(
|
||||
treatmentId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +320,7 @@ export class TreatmentsController {
|
||||
saveDraftByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Body() dto: SaveTreatmentDraftDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveDraftForTreatment(
|
||||
@@ -324,6 +328,7 @@ export class TreatmentsController {
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ export class TreatmentsController {
|
||||
saveLabCasesByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Body() dto: SaveTreatmentLabCasesDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveLabCasesForTreatment(
|
||||
@@ -340,6 +345,7 @@ export class TreatmentsController {
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificatio
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
|
||||
import {
|
||||
buildLabCaseShareUrl,
|
||||
generateLabCaseAccessToken,
|
||||
} from '../../common/lab-case-access-token';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
@@ -219,14 +222,7 @@ export class TreatmentsService {
|
||||
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
|
||||
patientId = sentinel.id;
|
||||
} else {
|
||||
await this.ensurePatientExists(dto.patientId!);
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: dto.patientId! },
|
||||
select: { isWalkIn: true },
|
||||
});
|
||||
if (patient?.isWalkIn) {
|
||||
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
await this.ensurePatientInOrg(dto.patientId!, organizationId);
|
||||
patientId = dto.patientId!;
|
||||
}
|
||||
|
||||
@@ -477,6 +473,7 @@ export class TreatmentsService {
|
||||
appointmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
const appointment = await this.ensureAppointmentProvider(
|
||||
@@ -493,16 +490,25 @@ export class TreatmentsService {
|
||||
include: treatmentInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
|
||||
if (treatment) {
|
||||
await this.ensureSentLabCaseAccessTokens(treatment.labCases);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: treatment ? this.mapTreatment(treatment, actorLanguage) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getDraftForTreatment(
|
||||
treatmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
return { success: true, data: this.mapTreatment(treatment) };
|
||||
await this.ensureSentLabCaseAccessTokens(treatment.labCases);
|
||||
return { success: true, data: this.mapTreatment(treatment, actorLanguage) };
|
||||
}
|
||||
|
||||
async saveDraftForAppointment(
|
||||
@@ -510,6 +516,7 @@ export class TreatmentsService {
|
||||
dto: SaveTreatmentDraftDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const appointment = await this.ensureAppointmentProvider(
|
||||
@@ -549,7 +556,7 @@ export class TreatmentsService {
|
||||
patientId: appointment.patientId,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(saved) };
|
||||
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
|
||||
}
|
||||
|
||||
async saveDraftForTreatment(
|
||||
@@ -557,6 +564,7 @@ export class TreatmentsService {
|
||||
dto: SaveTreatmentDraftDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
@@ -572,7 +580,7 @@ export class TreatmentsService {
|
||||
patientId: existing.patientId,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(saved) };
|
||||
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
|
||||
}
|
||||
|
||||
private async persistDraftDetails(args: {
|
||||
@@ -719,6 +727,7 @@ export class TreatmentsService {
|
||||
dto: SaveTreatmentLabCasesDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const appointment = await this.ensureAppointmentProvider(
|
||||
@@ -736,7 +745,13 @@ export class TreatmentsService {
|
||||
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId);
|
||||
return this.saveLabCasesForTreatment(
|
||||
treatment.id,
|
||||
dto,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
actorLanguage,
|
||||
);
|
||||
}
|
||||
|
||||
async saveLabCasesForTreatment(
|
||||
@@ -744,6 +759,7 @@ export class TreatmentsService {
|
||||
dto: SaveTreatmentLabCasesDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
actorLanguage?: string | null,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
@@ -907,7 +923,7 @@ export class TreatmentsService {
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(saved) };
|
||||
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
|
||||
}
|
||||
|
||||
async sendLabCase(
|
||||
@@ -1049,7 +1065,7 @@ export class TreatmentsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapLabCase(refreshed) };
|
||||
return { success: true, data: this.mapLabCase(refreshed, actorLanguage) };
|
||||
}
|
||||
|
||||
async updateLabCaseDueDate(
|
||||
@@ -1411,7 +1427,7 @@ export class TreatmentsService {
|
||||
organization: { id: string; name: string };
|
||||
}>;
|
||||
}>;
|
||||
}) {
|
||||
}, locale?: string | null) {
|
||||
const documents = treatment.details.flatMap((d) =>
|
||||
d.attachments.map((a) => this.mapAttachment(a)),
|
||||
);
|
||||
@@ -1434,7 +1450,7 @@ export class TreatmentsService {
|
||||
}
|
||||
: null,
|
||||
details: treatment.details.map((d) => this.mapDetail(d)),
|
||||
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
|
||||
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc, locale)),
|
||||
documents,
|
||||
};
|
||||
}
|
||||
@@ -1497,6 +1513,7 @@ export class TreatmentsService {
|
||||
destinationOrganizationId?: string | null;
|
||||
sentAt?: Date | null;
|
||||
dueDate?: Date | null;
|
||||
accessToken?: string | null;
|
||||
details?: Array<{
|
||||
treatmentDetailId: string;
|
||||
detail?: {
|
||||
@@ -1528,7 +1545,7 @@ export class TreatmentsService {
|
||||
};
|
||||
}>;
|
||||
tasks?: Array<{ id: string; status: LabTaskStatus }>;
|
||||
}) {
|
||||
}, locale?: string | null) {
|
||||
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
|
||||
const detailTeeth = lc.details?.[0]?.detail
|
||||
? normalizeTeeth(lc.details[0].detail.teeth)
|
||||
@@ -1572,9 +1589,32 @@ export class TreatmentsService {
|
||||
organizationName: s.organization?.name ?? 'Unknown organization',
|
||||
sentAt: s.sentAt.toISOString(),
|
||||
})) ?? [],
|
||||
shareUrl: this.labCaseShareUrl(lc, locale),
|
||||
};
|
||||
}
|
||||
|
||||
private labCaseShareUrl(
|
||||
lc: { sentAt?: Date | null; accessToken?: string | null },
|
||||
locale?: string | null,
|
||||
): string | null {
|
||||
if (!lc.sentAt || !lc.accessToken) return null;
|
||||
return buildLabCaseShareUrl(lc.accessToken, locale ?? 'en');
|
||||
}
|
||||
|
||||
private async ensureSentLabCaseAccessTokens(
|
||||
labCases: Array<{ id: string; sentAt: Date | null; accessToken: string | null }>,
|
||||
) {
|
||||
for (const lc of labCases) {
|
||||
if (!lc.sentAt || lc.accessToken) continue;
|
||||
const accessToken = generateLabCaseAccessToken();
|
||||
await this.prisma.labCase.update({
|
||||
where: { id: lc.id },
|
||||
data: { accessToken },
|
||||
});
|
||||
lc.accessToken = accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) {
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length;
|
||||
@@ -1623,6 +1663,20 @@ export class TreatmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: {
|
||||
id: patientId,
|
||||
isWalkIn: false,
|
||||
createdByOrganizationId: organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTreatmentProvider(
|
||||
treatmentId: string,
|
||||
organizationId: string,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
|
||||
# Build stage — produces `.next/standalone` (see next.config.ts output: standalone)
|
||||
FROM node:20-alpine AS builder
|
||||
FROM ${NODE_IMAGE} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,7 +35,7 @@ ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
|
||||
RUN npm run build
|
||||
|
||||
# Production — minimal runtime using Next.js standalone bundle
|
||||
FROM node:20-alpine AS runner
|
||||
FROM ${NODE_IMAGE} AS runner
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"labelCreatePassword": "Create password",
|
||||
"labelConfirmPassword": "Confirm password",
|
||||
"activateAccount": "Activate account",
|
||||
"setPasswordTitle": "Set your password",
|
||||
"setPasswordSubmit": "Set password",
|
||||
"passwordSetupAlreadyDone": "This password setup link is no longer valid. You can log in now.",
|
||||
"invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
|
||||
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
|
||||
"errorAcceptInvitation": "Could not accept invitation",
|
||||
@@ -322,6 +325,18 @@
|
||||
"disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.",
|
||||
"disableMemberButton": "Disable member",
|
||||
"editModalTitle": "Edit member",
|
||||
"removePassword": "Remove password",
|
||||
"copyPasswordSetupLink": "Copy password setup link",
|
||||
"removePasswordModalTitle": "Remove password",
|
||||
"removePasswordConfirm": "Remove the password for {name} ({email})?",
|
||||
"removePasswordBullet1": "They will not be able to sign in until they set a new password with the setup link.",
|
||||
"removePasswordBullet2": "You cannot choose their new password. Share the setup link with them.",
|
||||
"removePasswordBullet3": "This signs them out of every organization they belong to.",
|
||||
"removePasswordButton": "Remove password and copy link",
|
||||
"passwordSetupLinkHeading": "Password setup link",
|
||||
"passwordSetupShareHint": "Share this link so they can set a new password. Login will fail until they finish.",
|
||||
"successPasswordCleared": "Password removed for {name}. Share the setup link with them.",
|
||||
"errorClearPassword": "Could not remove the password.",
|
||||
"loadingWorkingHours": "Loading working hours…",
|
||||
"errorLoadStaff": "Failed to load staff.",
|
||||
"errorCopyInvite": "Could not copy invitation link.",
|
||||
@@ -1135,7 +1150,6 @@
|
||||
"noneYet": "None yet",
|
||||
"clearHint": "Clear this tooth removes it from the case. Use × on a chip to drop one type.",
|
||||
"removeJob": "Remove this type",
|
||||
"regionCrown": "Crown",
|
||||
"pickerTooth": "Tooth {tooth}",
|
||||
"slotCrown": "Crown",
|
||||
"suggestionsLabel": "Suggested add-ons",
|
||||
@@ -1215,6 +1229,7 @@
|
||||
"APPOINTMENT_NOT_FOUND": "Appointment not found.",
|
||||
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
|
||||
"PATIENT_NOT_FOUND": "Patient not found.",
|
||||
"PATIENT_MOBILE_UNAVAILABLE": "This mobile number cannot be added for this clinic.",
|
||||
"WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.",
|
||||
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.",
|
||||
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.",
|
||||
@@ -1238,6 +1253,8 @@
|
||||
"STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.",
|
||||
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
|
||||
"STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.",
|
||||
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "You cannot remove your own password here. Use account settings or forgot password.",
|
||||
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Password can only be removed for active members. Pending members use the invitation link.",
|
||||
"ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.",
|
||||
"ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).",
|
||||
"ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.",
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"labelCreatePassword": "ایجاد رمز عبور",
|
||||
"labelConfirmPassword": "تأیید رمز عبور",
|
||||
"activateAccount": "فعالسازی حساب",
|
||||
"setPasswordTitle": "رمز عبور خود را تنظیم کنید",
|
||||
"setPasswordSubmit": "تنظیم رمز عبور",
|
||||
"passwordSetupAlreadyDone": "این لینک تنظیم رمز دیگر معتبر نیست. اکنون میتوانید وارد شوید.",
|
||||
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
|
||||
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
|
||||
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
|
||||
@@ -322,6 +325,18 @@
|
||||
"disableBullet3": "غیرفعالسازی یک مجوز در طرح شما را آزاد میکند تا بتوانید شخص دیگری را دعوت کنید.",
|
||||
"disableMemberButton": "غیرفعالسازی عضو",
|
||||
"editModalTitle": "ویرایش عضو",
|
||||
"removePassword": "حذف رمز عبور",
|
||||
"copyPasswordSetupLink": "کپی لینک تنظیم رمز",
|
||||
"removePasswordModalTitle": "حذف رمز عبور",
|
||||
"removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟",
|
||||
"removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمیتوانند وارد شوند.",
|
||||
"removePasswordBullet2": "شما رمز جدید را انتخاب نمیکنید. لینک تنظیم را برایشان بفرستید.",
|
||||
"removePasswordBullet3": "از همه سازمانهایی که عضو آن هستند خارج میشوند.",
|
||||
"removePasswordButton": "حذف رمز و کپی لینک",
|
||||
"passwordSetupLinkHeading": "لینک تنظیم رمز عبور",
|
||||
"passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.",
|
||||
"successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.",
|
||||
"errorClearPassword": "حذف رمز عبور امکانپذیر نبود.",
|
||||
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
|
||||
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
|
||||
"errorCopyInvite": "کپی لینک دعوتنامه امکانپذیر نبود.",
|
||||
@@ -1136,7 +1151,6 @@
|
||||
"noneYet": "هنوز هیچ",
|
||||
"clearHint": "«پاک کردن این دندان» آن را از کیس حذف میکند. با × روی تراشه یک نوع را بردارید.",
|
||||
"removeJob": "حذف این نوع",
|
||||
"regionCrown": "تاج",
|
||||
"pickerTooth": "دندان {tooth}",
|
||||
"slotCrown": "روکش",
|
||||
"suggestionsLabel": "افزودنیهای پیشنهادی",
|
||||
@@ -1216,6 +1230,7 @@
|
||||
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
|
||||
"APPOINTMENT_NOT_PROVIDER": "شما ارائهدهنده این نوبت نیستید.",
|
||||
"PATIENT_NOT_FOUND": "بیمار یافت نشد.",
|
||||
"PATIENT_MOBILE_UNAVAILABLE": "این شماره موبایل را نمیتوان برای این کلینیک ثبت کرد.",
|
||||
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. همپوشانی شیفتها را بررسی کنید.",
|
||||
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
|
||||
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبتهای آینده تداخل دارد. ابتدا آن نوبتها را تغییر دهید یا حذف کنید.",
|
||||
@@ -1239,6 +1254,8 @@
|
||||
"STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمیتوان اینگونه فعال کرد.",
|
||||
"STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمیتوان غیرفعال کرد.",
|
||||
"STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمیتوان حذف کرد.",
|
||||
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "نمیتوانید رمز عبور خود را از اینجا حذف کنید. از تنظیمات حساب یا فراموشی رمز استفاده کنید.",
|
||||
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "رمز عبور را فقط برای اعضای فعال میتوان حذف کرد. اعضای در انتظار از لینک دعوت استفاده میکنند.",
|
||||
"ORG_CANNOT_LINK_SELF": "نمیتوانید سازمان را به خودش متصل کنید.",
|
||||
"ORG_LINK_WRONG_TYPE": "فقط میتوانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
|
||||
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"labelCreatePassword": "Wachtwoord aanmaken",
|
||||
"labelConfirmPassword": "Bevestig wachtwoord",
|
||||
"activateAccount": "Account activeren",
|
||||
"setPasswordTitle": "Stel uw wachtwoord in",
|
||||
"setPasswordSubmit": "Wachtwoord instellen",
|
||||
"passwordSetupAlreadyDone": "Deze wachtwoordlink is niet meer geldig. U kunt nu inloggen.",
|
||||
"invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...",
|
||||
"invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.",
|
||||
"errorAcceptInvitation": "Kon uitnodiging niet accepteren",
|
||||
@@ -322,6 +325,18 @@
|
||||
"disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.",
|
||||
"disableMemberButton": "Lid uitschakelen",
|
||||
"editModalTitle": "Lid bewerken",
|
||||
"removePassword": "Wachtwoord verwijderen",
|
||||
"copyPasswordSetupLink": "Wachtwoordlink kopiëren",
|
||||
"removePasswordModalTitle": "Wachtwoord verwijderen",
|
||||
"removePasswordConfirm": "Wachtwoord van {name} ({email}) verwijderen?",
|
||||
"removePasswordBullet1": "Zij kunnen niet inloggen tot ze via de instellink een nieuw wachtwoord kiezen.",
|
||||
"removePasswordBullet2": "U kunt hun nieuwe wachtwoord niet kiezen. Deel de instellink met hen.",
|
||||
"removePasswordBullet3": "Dit meldt hen af bij elke organisatie waar zij lid van zijn.",
|
||||
"removePasswordButton": "Wachtwoord verwijderen en link kopiëren",
|
||||
"passwordSetupLinkHeading": "Wachtwoord-instellink",
|
||||
"passwordSetupShareHint": "Deel deze link zodat zij een nieuw wachtwoord kunnen instellen. Inloggen mislukt tot dat is afgerond.",
|
||||
"successPasswordCleared": "Wachtwoord van {name} is verwijderd. Deel de instellink met hen.",
|
||||
"errorClearPassword": "Kon het wachtwoord niet verwijderen.",
|
||||
"loadingWorkingHours": "Werktijden laden...",
|
||||
"errorLoadStaff": "Medewerkers laden mislukt.",
|
||||
"errorCopyInvite": "Kon uitnodigingslink niet kopiëren.",
|
||||
@@ -1135,7 +1150,6 @@
|
||||
"noneYet": "Nog geen",
|
||||
"clearHint": "Deze tand wissen verwijdert hem uit de case. Gebruik × op een chip om één type te verwijderen.",
|
||||
"removeJob": "Dit type verwijderen",
|
||||
"regionCrown": "Kroon",
|
||||
"pickerTooth": "Tand {tooth}",
|
||||
"slotCrown": "Kroon",
|
||||
"suggestionsLabel": "Voorgestelde toevoegingen",
|
||||
@@ -1215,6 +1229,7 @@
|
||||
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
|
||||
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
|
||||
"PATIENT_NOT_FOUND": "Patiënt niet gevonden.",
|
||||
"PATIENT_MOBILE_UNAVAILABLE": "Dit mobiele nummer kan niet voor deze kliniek worden toegevoegd.",
|
||||
"WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.",
|
||||
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.",
|
||||
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.",
|
||||
@@ -1238,6 +1253,8 @@
|
||||
"STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.",
|
||||
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
|
||||
"STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.",
|
||||
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "U kunt hier uw eigen wachtwoord niet verwijderen. Gebruik accountinstellingen of wachtwoord vergeten.",
|
||||
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Het wachtwoord kan alleen voor actieve leden worden verwijderd. Leden in afwachting gebruiken de uitnodigingslink.",
|
||||
"ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.",
|
||||
"ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).",
|
||||
"ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.",
|
||||
|
||||
5
frontend/public/prosthesis-catalog/crown.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
9
frontend/public/prosthesis-catalog/crown_anatomical.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
25
frontend/public/prosthesis-catalog/crown_full_metal.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
19
frontend/public/prosthesis-catalog/crown_pfm.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
8
frontend/public/prosthesis-catalog/denture.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
6
frontend/public/prosthesis-catalog/implant.svg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
6
frontend/public/prosthesis-catalog/inlay.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
9
frontend/public/prosthesis-catalog/mock_up.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
8
frontend/public/prosthesis-catalog/night_guard.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
6
frontend/public/prosthesis-catalog/onlay.svg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
9
frontend/public/prosthesis-catalog/overdenture.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
6
frontend/public/prosthesis-catalog/overlay.svg
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
9
frontend/public/prosthesis-catalog/partial_denture.svg
Normal file
|
After Width: | Height: | Size: 41 KiB |
7
frontend/public/prosthesis-catalog/pfz_crown_layered.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
87
frontend/public/prosthesis-catalog/post_and_core.svg
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
|
||||
|
||||
|
||||
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C163 185 160 194 159 205
|
||||
C157 232 153 260 146 285
|
||||
C140 304 132 316 124 319
|
||||
C119 321 116 317 117 310
|
||||
C120 289 119 270 116 249
|
||||
C114 237 111 228 110 223
|
||||
C109 228 106 237 104 249
|
||||
C101 270 100 289 103 310
|
||||
C104 317 101 321 96 319
|
||||
C88 316 80 304 74 285
|
||||
C67 260 63 232 61 205
|
||||
C60 194 57 185 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#F3F3F3"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C164 183 160 187 154 187
|
||||
H145
|
||||
L138 137
|
||||
C137 131 133 128 127 128
|
||||
H93
|
||||
C87 128 83 131 82 137
|
||||
L75 187
|
||||
H66
|
||||
C60 187 56 183 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#FFFFFF"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Core -->
|
||||
<path d="
|
||||
M82 137
|
||||
C83 131 87 128 93 128
|
||||
H127
|
||||
C133 128 137 131 138 137
|
||||
L145 187
|
||||
H75 Z"
|
||||
fill="#9A9A9A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Post -->
|
||||
<path d="
|
||||
M94 187
|
||||
H126
|
||||
L121 257
|
||||
C120 273 116 282 110 282
|
||||
C104 282 100 273 99 257
|
||||
Z"
|
||||
fill="#8A8A8A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
8
frontend/public/prosthesis-catalog/removable.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
5
frontend/public/prosthesis-catalog/screw_retained.svg
Normal file
|
After Width: | Height: | Size: 31 KiB |
9
frontend/public/prosthesis-catalog/smile_design.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
9
frontend/public/prosthesis-catalog/surgical_guide.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
5
frontend/public/prosthesis-catalog/ti_base_abutment.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
6
frontend/public/prosthesis-catalog/veneer.svg
Normal file
@@ -30,6 +30,7 @@ function AcceptInviteContent() {
|
||||
organizationName: string;
|
||||
expiresAt: string;
|
||||
status: 'PENDING' | 'ACCEPTED';
|
||||
mode: 'join' | 'password_setup';
|
||||
} | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
@@ -48,10 +49,17 @@ function AcceptInviteContent() {
|
||||
setError('');
|
||||
try {
|
||||
const res = await staffApi.previewInvite(token);
|
||||
setInviteInfo(res.data);
|
||||
setInviteInfo({
|
||||
...res.data,
|
||||
mode: res.data.mode === 'password_setup' ? 'password_setup' : 'join',
|
||||
});
|
||||
setName(res.data.name || '');
|
||||
if (res.data.status === 'ACCEPTED') {
|
||||
setSuccess(t('invitationAlreadyAccepted'));
|
||||
setSuccess(
|
||||
res.data.mode === 'password_setup'
|
||||
? t('passwordSetupAlreadyDone')
|
||||
: t('invitationAlreadyAccepted'),
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
|
||||
@@ -65,7 +73,9 @@ function AcceptInviteContent() {
|
||||
if (!token) return;
|
||||
setError('');
|
||||
setSuccess('');
|
||||
if (!name.trim()) {
|
||||
const isPasswordSetup = inviteInfo?.mode === 'password_setup';
|
||||
const nameToSubmit = isPasswordSetup ? (inviteInfo?.name || '').trim() : name.trim();
|
||||
if (!isPasswordSetup && !nameToSubmit) {
|
||||
setError(t('nameRequired'));
|
||||
return;
|
||||
}
|
||||
@@ -83,7 +93,7 @@ function AcceptInviteContent() {
|
||||
try {
|
||||
await staffApi.acceptInvite({
|
||||
token,
|
||||
name: name.trim(),
|
||||
name: nameToSubmit || inviteInfo?.name || '',
|
||||
password,
|
||||
});
|
||||
accepted = true;
|
||||
@@ -115,7 +125,9 @@ function AcceptInviteContent() {
|
||||
return (
|
||||
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-md surface-card p-4 sm:p-6 space-y-5">
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-text-primary">{t('acceptInviteTitle')}</h1>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-text-primary">
|
||||
{inviteInfo?.mode === 'password_setup' ? t('setPasswordTitle') : t('acceptInviteTitle')}
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingInvitation')}</p>
|
||||
@@ -147,7 +159,9 @@ function AcceptInviteContent() {
|
||||
|
||||
{inviteInfo?.status !== 'ACCEPTED' && (
|
||||
<div className="space-y-3">
|
||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{inviteInfo?.mode !== 'password_setup' && (
|
||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
)}
|
||||
<Input
|
||||
label={t('labelCreatePassword')}
|
||||
type="password"
|
||||
@@ -163,7 +177,7 @@ function AcceptInviteContent() {
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
|
||||
{t('activateAccount')}
|
||||
{inviteInfo?.mode === 'password_setup' ? t('setPasswordSubmit') : t('activateAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
5
frontend/src/assets/prosthesis-catalog/crown.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
25
frontend/src/assets/prosthesis-catalog/crown_full_metal.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
19
frontend/src/assets/prosthesis-catalog/crown_pfm.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
8
frontend/src/assets/prosthesis-catalog/denture.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
6
frontend/src/assets/prosthesis-catalog/implant.svg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
6
frontend/src/assets/prosthesis-catalog/inlay.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
9
frontend/src/assets/prosthesis-catalog/mock_up.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
8
frontend/src/assets/prosthesis-catalog/night_guard.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
6
frontend/src/assets/prosthesis-catalog/onlay.svg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
9
frontend/src/assets/prosthesis-catalog/overdenture.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
6
frontend/src/assets/prosthesis-catalog/overlay.svg
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 18 KiB |
87
frontend/src/assets/prosthesis-catalog/post_and_core.svg
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
|
||||
|
||||
|
||||
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C163 185 160 194 159 205
|
||||
C157 232 153 260 146 285
|
||||
C140 304 132 316 124 319
|
||||
C119 321 116 317 117 310
|
||||
C120 289 119 270 116 249
|
||||
C114 237 111 228 110 223
|
||||
C109 228 106 237 104 249
|
||||
C101 270 100 289 103 310
|
||||
C104 317 101 321 96 319
|
||||
C88 316 80 304 74 285
|
||||
C67 260 63 232 61 205
|
||||
C60 194 57 185 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#F3F3F3"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
|
||||
<path d="
|
||||
M29 62
|
||||
C38 41 57 31 77 34
|
||||
C89 36 98 42 110 42
|
||||
C122 42 131 36 143 34
|
||||
C163 31 182 41 191 62
|
||||
C201 86 198 116 187 142
|
||||
C181 156 174 168 168 177
|
||||
C164 183 160 187 154 187
|
||||
H145
|
||||
L138 137
|
||||
C137 131 133 128 127 128
|
||||
H93
|
||||
C87 128 83 131 82 137
|
||||
L75 187
|
||||
H66
|
||||
C60 187 56 183 52 177
|
||||
C46 168 39 156 33 142
|
||||
C22 116 19 86 29 62 Z"
|
||||
fill="#FFFFFF"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Core -->
|
||||
<path d="
|
||||
M82 137
|
||||
C83 131 87 128 93 128
|
||||
H127
|
||||
C133 128 137 131 138 137
|
||||
L145 187
|
||||
H75 Z"
|
||||
fill="#9A9A9A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Post -->
|
||||
<path d="
|
||||
M94 187
|
||||
H126
|
||||
L121 257
|
||||
C120 273 116 282 110 282
|
||||
C104 282 100 273 99 257
|
||||
Z"
|
||||
fill="#8A8A8A"
|
||||
stroke="#243549"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
8
frontend/src/assets/prosthesis-catalog/removable.svg
Normal file
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 31 KiB |
9
frontend/src/assets/prosthesis-catalog/smile_design.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
6
frontend/src/assets/prosthesis-catalog/veneer.svg
Normal file
99
frontend/src/components/treatment/prosthesisCatalogIcons.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
const BASE = '/prosthesis-catalog';
|
||||
|
||||
const icon = {
|
||||
crown: `${BASE}/crown.svg`,
|
||||
crown_anatomical: `${BASE}/crown_anatomical.svg`,
|
||||
crown_full_metal: `${BASE}/crown_full_metal.svg`,
|
||||
crown_pfm: `${BASE}/crown_pfm.svg`,
|
||||
custom_abutment_titanium: `${BASE}/custom_abutment_titanium.svg`,
|
||||
custom_abutment_zirconia: `${BASE}/custom_abutment_zirconia.svg`,
|
||||
denture: `${BASE}/denture.svg`,
|
||||
implant: `${BASE}/implant.svg`,
|
||||
inlay: `${BASE}/inlay.svg`,
|
||||
mock_up: `${BASE}/mock_up.svg`,
|
||||
multi_unit_abutment: `${BASE}/multi_unit_abutment.svg`,
|
||||
night_guard: `${BASE}/night_guard.svg`,
|
||||
onlay: `${BASE}/onlay.svg`,
|
||||
overdenture: `${BASE}/overdenture.svg`,
|
||||
overlay: `${BASE}/overlay.svg`,
|
||||
partial_denture: `${BASE}/partial_denture.svg`,
|
||||
pfz_crown_layered: `${BASE}/pfz_crown_layered.svg`,
|
||||
post_and_core: `${BASE}/post_and_core.svg`,
|
||||
prefabricated_abutment: `${BASE}/prefabricated_abutment.svg`,
|
||||
removable: `${BASE}/removable.svg`,
|
||||
screw_retained: `${BASE}/screw_retained.svg`,
|
||||
smile_design: `${BASE}/smile_design.svg`,
|
||||
surgical_guide: `${BASE}/surgical_guide.svg`,
|
||||
ti_base_abutment: `${BASE}/ti_base_abutment.svg`,
|
||||
veneer: `${BASE}/veneer.svg`,
|
||||
} as const;
|
||||
|
||||
const BY_CODE: Record<string, string> = {
|
||||
pfm_crown: icon.crown_pfm,
|
||||
pfz_crown: icon.pfz_crown_layered,
|
||||
full_metal_crown: icon.crown_full_metal,
|
||||
monolithic_zirconia: icon.crown_anatomical,
|
||||
glass_ceramic_crown: icon.crown_anatomical,
|
||||
temporary_resin_crown: icon.crown_anatomical,
|
||||
pmma: icon.crown_anatomical,
|
||||
peek_crown: icon.crown_anatomical,
|
||||
press_ceramic: icon.crown_anatomical,
|
||||
veneer_full_contour: icon.veneer,
|
||||
veneer_layered: icon.veneer,
|
||||
inlay_full_contour: icon.inlay,
|
||||
inlay_layered: icon.inlay,
|
||||
onlay_full_contour: icon.onlay,
|
||||
onlay_layered: icon.onlay,
|
||||
overlay_full_contour: icon.overlay,
|
||||
overlay_layered: icon.overlay,
|
||||
cast_post_core: icon.post_and_core,
|
||||
fiber_post_core: icon.post_and_core,
|
||||
prefabricated_abutment: icon.prefabricated_abutment,
|
||||
ti_base_abutment: icon.ti_base_abutment,
|
||||
multi_unit_abutment: icon.multi_unit_abutment,
|
||||
customized_abutment: icon.custom_abutment_titanium,
|
||||
zirconia_abutment: icon.custom_abutment_zirconia,
|
||||
screw_retained: icon.screw_retained,
|
||||
complete_denture: icon.denture,
|
||||
partial_denture: icon.partial_denture,
|
||||
overdenture: icon.overdenture,
|
||||
night_guard_soft: icon.night_guard,
|
||||
night_guard_hard: icon.night_guard,
|
||||
night_guard_dual: icon.night_guard,
|
||||
surgical_guide: icon.surgical_guide,
|
||||
smile_design: icon.smile_design,
|
||||
mockup: icon.mock_up,
|
||||
};
|
||||
|
||||
const BY_SUBCATEGORY: Record<string, string> = {
|
||||
veneer: icon.veneer,
|
||||
inlay: icon.inlay,
|
||||
onlay: icon.onlay,
|
||||
overlay: icon.overlay,
|
||||
night_guard: icon.night_guard,
|
||||
};
|
||||
|
||||
const BY_CATEGORY: Record<string, string> = {
|
||||
crown: icon.crown,
|
||||
implant: icon.implant,
|
||||
post_core: icon.post_and_core,
|
||||
indirect: icon.inlay,
|
||||
removable: icon.removable,
|
||||
appliance: icon.night_guard,
|
||||
digital: icon.smile_design,
|
||||
};
|
||||
|
||||
export function prosthesisCatalogIconSrc(opts: {
|
||||
code?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
}): string | undefined {
|
||||
const code = opts.code?.trim();
|
||||
if (code && BY_CODE[code]) return BY_CODE[code];
|
||||
const category = opts.category?.trim();
|
||||
if (code && category === 'crown') return icon.crown_anatomical;
|
||||
const sub = opts.subcategory?.trim();
|
||||
if (sub && BY_SUBCATEGORY[sub]) return BY_SUBCATEGORY[sub];
|
||||
if (category && BY_CATEGORY[category]) return BY_CATEGORY[category];
|
||||
return undefined;
|
||||
}
|
||||
83
frontend/src/components/treatment/prosthesisJobPath.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { splitProsthesisGroupCode } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
|
||||
/** Non-breaking spaces so the gap around · does not collapse between inline spans. */
|
||||
export const PROSTHESIS_JOB_PATH_SEP = '\u00a0·\u00a0';
|
||||
|
||||
type CatalogLite = readonly Pick<
|
||||
ProsthesisCatalogEntry,
|
||||
'code' | 'label' | 'category' | 'subcategory'
|
||||
>[];
|
||||
|
||||
export type ProsthesisMessageFn = (key: string) => string;
|
||||
|
||||
function sameLabel(a: string, b: string): boolean {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
|
||||
}
|
||||
|
||||
function leafFromCatalogLabel(label: string): string {
|
||||
const parts = label
|
||||
.split(/\s*·\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 1 ? parts[parts.length - 1] : label;
|
||||
}
|
||||
|
||||
function humanizeCode(code: string): string {
|
||||
return code
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Picker root whose title is a slash-joined list of its children (redundant in job titles). */
|
||||
const SKIP_CATEGORY_IN_JOB_PATH = new Set(['indirect']);
|
||||
|
||||
/** Category → subcategory → last picker node for one catalog code. */
|
||||
export function prosthesisJobPathParts(
|
||||
code: string,
|
||||
catalog: CatalogLite,
|
||||
t: ProsthesisMessageFn,
|
||||
): { ancestors: string[]; leaf: string } {
|
||||
const entry = catalog.find((item) => item.code === code);
|
||||
if (!entry) {
|
||||
return { ancestors: [], leaf: humanizeCode(code) };
|
||||
}
|
||||
|
||||
const ancestors: string[] = [];
|
||||
const subcategory = entry.subcategory?.trim() ?? '';
|
||||
const skipCategory = SKIP_CATEGORY_IN_JOB_PATH.has(entry.category);
|
||||
if (entry.category && !skipCategory) {
|
||||
ancestors.push(t(`category_${entry.category}`));
|
||||
}
|
||||
if (subcategory) {
|
||||
ancestors.push(t(`sub_${subcategory}`));
|
||||
}
|
||||
|
||||
const leaf = leafFromCatalogLabel(entry.label) || entry.label || code;
|
||||
const uniqueAncestors: string[] = [];
|
||||
for (const ancestor of ancestors) {
|
||||
if (!ancestor || sameLabel(ancestor, leaf)) continue;
|
||||
if (uniqueAncestors.some((existing) => sameLabel(existing, ancestor))) continue;
|
||||
uniqueAncestors.push(ancestor);
|
||||
}
|
||||
return { ancestors: uniqueAncestors, leaf };
|
||||
}
|
||||
|
||||
function formatProsthesisJobPath(
|
||||
parts: { ancestors: string[]; leaf: string },
|
||||
): string {
|
||||
return [...parts.ancestors, parts.leaf].filter(Boolean).join(PROSTHESIS_JOB_PATH_SEP);
|
||||
}
|
||||
|
||||
/** Full picker path for a type or stacked `code+code` group. */
|
||||
export function prosthesisJobPathLabel(
|
||||
code: string,
|
||||
catalog: CatalogLite,
|
||||
t: ProsthesisMessageFn,
|
||||
): string {
|
||||
return splitProsthesisGroupCode(code)
|
||||
.map((part) => formatProsthesisJobPath(prosthesisJobPathParts(part, catalog, t)))
|
||||
.join(' + ');
|
||||
}
|
||||
@@ -79,6 +79,11 @@ export function categoryCodesInCatalog(
|
||||
return order.filter((code) => present.has(code));
|
||||
}
|
||||
|
||||
/** Matches category titles (e.g. Veneer/Inlay/Onlay/Overlay), not alphabetical codes. */
|
||||
const SUBCATEGORY_ORDER: Record<string, readonly string[]> = {
|
||||
indirect: ['veneer', 'inlay', 'onlay', 'overlay'],
|
||||
};
|
||||
|
||||
export function subcategoriesFor(
|
||||
catalog: readonly ProsthesisCatalogEntry[],
|
||||
category: string,
|
||||
@@ -94,7 +99,10 @@ export function subcategoriesFor(
|
||||
)
|
||||
.map((e) => e.subcategory),
|
||||
);
|
||||
return [...set].sort();
|
||||
const preferred = SUBCATEGORY_ORDER[category];
|
||||
if (!preferred) return [...set].sort();
|
||||
const rest = [...set].filter((code) => !preferred.includes(code)).sort();
|
||||
return [...preferred.filter((code) => set.has(code)), ...rest];
|
||||
}
|
||||
|
||||
export function leavesFor(
|
||||
|
||||
@@ -187,12 +187,3 @@ export function formatToothList(
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function prosthesisGroupLabelFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'label'>[],
|
||||
): string {
|
||||
const parts = splitProsthesisGroupCode(code);
|
||||
if (parts.length === 0) return code;
|
||||
return parts.map((part) => catalog.find((entry) => entry.code === part)?.label ?? part).join(' + ');
|
||||
}
|
||||
|
||||
@@ -312,10 +312,9 @@ export function CaseDetailPanel({
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="max-w-[16rem] text-sm font-medium"
|
||||
className="min-w-0 whitespace-normal break-words text-sm font-medium"
|
||||
code={group.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={group.prosthesisTypeLabel}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{formatToothList(group.teeth, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CaseSheetFdiChart } from '@/components/ui/lab/CaseSheetFdiChart';
|
||||
import {
|
||||
buildCaseConnectedTeeth,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
formatPatientName,
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
|
||||
import type { LabCaseDetail } from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
@@ -54,6 +56,7 @@ export function CaseSheetPrintLayout({
|
||||
labels,
|
||||
prosthesisCatalog,
|
||||
}: CaseSheetPrintLayoutProps) {
|
||||
const tProsthesis = useTranslations('prosthesis');
|
||||
const prosthesisRows = buildCaseProsthesisRows(labCase);
|
||||
const connectedTeeth = buildCaseConnectedTeeth(labCase);
|
||||
const selected = new Set<FdiToothId>();
|
||||
@@ -156,16 +159,11 @@ export function CaseSheetPrintLayout({
|
||||
<li style={{ color: '#64748b' }}>—</li>
|
||||
) : (
|
||||
prosthesisRows.map((row) => {
|
||||
const fromCatalog = prosthesisCatalog.find(
|
||||
(e) => e.code === row.prosthesisTypeCode,
|
||||
)?.label;
|
||||
const fromTasks = labCase.tasksByTooth.find(
|
||||
(g) => g.prosthesisTypeCode === row.prosthesisTypeCode,
|
||||
)?.prosthesisTypeLabel;
|
||||
const label =
|
||||
fromCatalog?.trim() ||
|
||||
fromTasks?.trim() ||
|
||||
row.prosthesisTypeCode.replace(/_/g, ' ');
|
||||
const label = prosthesisJobPathLabel(
|
||||
row.prosthesisTypeCode,
|
||||
prosthesisCatalog,
|
||||
(key) => tProsthesis(key as never),
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${row.selectionGroupId ?? ''}-${row.prosthesisTypeCode}-${row.teeth.join(',')}`}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -45,6 +46,7 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export function CasesPage() {
|
||||
const t = useTranslations('cases');
|
||||
const tProsthesis = useTranslations('prosthesis');
|
||||
const tErrors = useTranslations('errors');
|
||||
const tCommon = useTranslations('common');
|
||||
const { currentOrganization, user } = useAuth();
|
||||
@@ -90,8 +92,8 @@ export function CasesPage() {
|
||||
|
||||
const prosthesisLabel = useCallback(
|
||||
(code: string) =>
|
||||
prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code,
|
||||
[prosthesisCatalog],
|
||||
prosthesisJobPathLabel(code, prosthesisCatalog, (key) => tProsthesis(key as never)),
|
||||
[prosthesisCatalog, tProsthesis],
|
||||
);
|
||||
|
||||
const treatmentDetailLabel = useCallback(
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisGroupLabelFromCatalog,
|
||||
prosthesisTypeLabelStyleFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
@@ -23,10 +19,6 @@ interface LabCaseProsthesisGroupsListProps {
|
||||
fallbackTeeth?: string[];
|
||||
}
|
||||
|
||||
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
|
||||
return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog);
|
||||
}
|
||||
|
||||
export function LabCaseProsthesisGroupsList({
|
||||
groups,
|
||||
prosthesisCatalog,
|
||||
@@ -109,12 +101,11 @@ export function LabCaseToothJobsList({
|
||||
<span className="text-text-muted" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="font-medium"
|
||||
style={prosthesisTypeLabelStyleFromCatalog(code, prosthesisCatalog)}
|
||||
>
|
||||
{prosthesisLabel(code, prosthesisCatalog)}
|
||||
</span>
|
||||
code={code}
|
||||
catalog={prosthesisCatalog}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{row.connected ? (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
prosthesisGroupLabelFromCatalog,
|
||||
prosthesisTypeLabelStyleFromCatalog,
|
||||
splitProsthesisGroupCode,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import {
|
||||
PROSTHESIS_JOB_PATH_SEP,
|
||||
prosthesisJobPathLabel,
|
||||
prosthesisJobPathParts,
|
||||
} from '@/components/treatment/prosthesisJobPath';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface ProsthesisStackedTypeLabelProps {
|
||||
@@ -20,31 +25,28 @@ export function ProsthesisStackedTypeLabel({
|
||||
className,
|
||||
title,
|
||||
}: ProsthesisStackedTypeLabelProps) {
|
||||
const t = useTranslations('prosthesis');
|
||||
const tr = (key: string) => t(key as never);
|
||||
const parts = splitProsthesisGroupCode(code);
|
||||
const fullTitle = title ?? prosthesisGroupLabelFromCatalog(code, catalog);
|
||||
if (parts.length <= 1) {
|
||||
const part = parts[0] ?? code;
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
title={fullTitle}
|
||||
style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}
|
||||
>
|
||||
{catalog.find((entry) => entry.code === part)?.label ?? part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const tooltip = title?.trim() || prosthesisJobPathLabel(code, catalog, tr);
|
||||
|
||||
return (
|
||||
<span className={className} title={fullTitle}>
|
||||
{parts.map((part, index) => (
|
||||
<span key={`${part}-${index}`}>
|
||||
{index > 0 ? <span className="text-text-muted"> + </span> : null}
|
||||
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>
|
||||
{catalog.find((entry) => entry.code === part)?.label ?? part}
|
||||
<span className={className} title={tooltip}>
|
||||
{parts.map((part, index) => {
|
||||
const path = prosthesisJobPathParts(part, catalog, tr);
|
||||
return (
|
||||
<span key={`${part}-${index}`}>
|
||||
{index > 0 ? <span className="text-text-muted"> + </span> : null}
|
||||
{path.ancestors.length > 0 ? (
|
||||
<span className="font-normal text-text-muted">
|
||||
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
|
||||
{PROSTHESIS_JOB_PATH_SEP}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>{path.leaf}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,9 @@ export function TaskProsthesisGroupHeader({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="max-w-[16rem] text-xs font-medium"
|
||||
className="min-w-0 whitespace-normal break-words text-xs font-medium"
|
||||
code={group.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={group.prosthesisTypeLabel}
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}
|
||||
|
||||
@@ -206,10 +206,9 @@ export function TaskRow({
|
||||
</Button>
|
||||
) : null}
|
||||
<ProsthesisStackedTypeLabel
|
||||
className="w-full max-w-[10rem] text-xs font-medium sm:w-[8rem]"
|
||||
className="min-w-0 max-w-full whitespace-normal break-words text-xs font-medium sm:max-w-[16rem] sm:text-end"
|
||||
code={task.prosthesisTypeCode}
|
||||
catalog={prosthesisCatalog}
|
||||
title={task.prosthesisTypeLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -79,6 +79,10 @@ function canShareStaffInviteLink(member: StaffMemberDto): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function canIssuePasswordSetup(member: StaffMemberDto, actorUserId?: string): boolean {
|
||||
return !member.isOwner && member.isActive && member.userId !== actorUserId;
|
||||
}
|
||||
|
||||
function canDisableStaff(member: StaffMemberDto): boolean {
|
||||
return !member.isOwner && member.isActive;
|
||||
}
|
||||
@@ -189,6 +193,12 @@ export function StaffPage() {
|
||||
invitationStatus: 'PENDING' | 'ACCEPTED';
|
||||
} | null>(null);
|
||||
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
|
||||
const [lastPasswordSetupInfo, setLastPasswordSetupInfo] = useState<{
|
||||
membershipId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
invitationUrl: string;
|
||||
} | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
|
||||
const [editStep, setEditStep] = useState<1 | 2>(1);
|
||||
@@ -205,6 +215,8 @@ export function StaffPage() {
|
||||
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
|
||||
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
|
||||
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
||||
const [clearPasswordTarget, setClearPasswordTarget] = useState<StaffMemberDto | null>(null);
|
||||
const [clearingMembershipId, setClearingMembershipId] = useState<string | null>(null);
|
||||
|
||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||
const highlightMembershipIds = useMemo(
|
||||
@@ -520,6 +532,45 @@ export function StaffPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function issuePasswordSetupLink(member: StaffMemberDto, copyToClipboard: boolean) {
|
||||
setClearingMembershipId(member.id);
|
||||
toast.setError('');
|
||||
try {
|
||||
const res = await staffApi.clearPassword(member.id);
|
||||
setLastPasswordSetupInfo({
|
||||
membershipId: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
invitationUrl: res.data.invitationUrl,
|
||||
});
|
||||
setClearPasswordTarget(null);
|
||||
setEditing(null);
|
||||
setEditStep(1);
|
||||
await load();
|
||||
toast.showSuccess(t('successPasswordCleared', { name: member.name }));
|
||||
if (copyToClipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(res.data.invitationUrl);
|
||||
setCopiedInviteMembershipId(member.id);
|
||||
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||
} catch {
|
||||
/* banner still shows the URL */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword')));
|
||||
} finally {
|
||||
setClearingMembershipId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmClearPassword() {
|
||||
if (!clearPasswordTarget || !canIssuePasswordSetup(clearPasswordTarget, user?.id)) {
|
||||
return;
|
||||
}
|
||||
await issuePasswordSetupLink(clearPasswordTarget, true);
|
||||
}
|
||||
|
||||
if (!currentOrganization || !canViewStaff(currentOrganization)) {
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">{t('redirecting')}</p>
|
||||
@@ -640,6 +691,54 @@ export function StaffPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastPasswordSetupInfo && (
|
||||
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 p-1.5 rounded-[var(--radius-sm)] text-text-muted hover:text-text-primary hover:bg-background-card/80"
|
||||
aria-label={tCommon('dismiss')}
|
||||
onClick={() => setLastPasswordSetupInfo(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<p className="text-sm text-text-primary pr-6">
|
||||
{t('successPasswordCleared', { name: lastPasswordSetupInfo.name })}
|
||||
</p>
|
||||
<div className="space-y-2 pt-1 border-t border-border/60">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
|
||||
{t('passwordSetupLinkHeading')}
|
||||
</p>
|
||||
<code className="block text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
|
||||
{lastPasswordSetupInfo.invitationUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={clearingMembershipId === lastPasswordSetupInfo.membershipId}
|
||||
onClick={async () => {
|
||||
setClearingMembershipId(lastPasswordSetupInfo.membershipId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastPasswordSetupInfo.invitationUrl);
|
||||
setCopiedInviteMembershipId(lastPasswordSetupInfo.membershipId);
|
||||
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||
} catch (e) {
|
||||
toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword')));
|
||||
} finally {
|
||||
setClearingMembershipId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copiedInviteMembershipId === lastPasswordSetupInfo.membershipId
|
||||
? tCommon('copied')
|
||||
: tCommon('copyLink')}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted">{t('passwordSetupShareHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
|
||||
) : (
|
||||
@@ -1061,6 +1160,59 @@ export function StaffPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clearPasswordTarget && (
|
||||
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
|
||||
<div
|
||||
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="clear-password-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 id="clear-password-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
{t('removePasswordModalTitle')}
|
||||
</h2>
|
||||
<DialogCloseButton
|
||||
onClick={() => {
|
||||
if (clearingMembershipId) return;
|
||||
setClearPasswordTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('removePasswordConfirm', {
|
||||
name: clearPasswordTarget.name,
|
||||
email: clearPasswordTarget.email,
|
||||
})}
|
||||
</p>
|
||||
<ul className="text-sm text-text-secondary space-y-2 list-disc ps-5">
|
||||
<li>{t('removePasswordBullet1')}</li>
|
||||
<li>{t('removePasswordBullet2')}</li>
|
||||
<li>{t('removePasswordBullet3')}</li>
|
||||
</ul>
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={Boolean(clearingMembershipId)}
|
||||
onClick={() => setClearPasswordTarget(null)}
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
isLoading={clearingMembershipId === clearPasswordTarget.id}
|
||||
disabled={Boolean(clearingMembershipId)}
|
||||
onClick={() => confirmClearPassword()}
|
||||
>
|
||||
{t('removePasswordButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
|
||||
<div
|
||||
@@ -1099,6 +1251,31 @@ export function StaffPage() {
|
||||
organizationType={currentOrganization?.type}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && canIssuePasswordSetup(editing, user?.id) && (
|
||||
<div className="pt-3 border-t border-border/60">
|
||||
{editing.hasPassword ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={Boolean(clearingMembershipId)}
|
||||
onClick={() => setClearPasswordTarget(editing)}
|
||||
>
|
||||
{t('removePassword')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={clearingMembershipId === editing.id}
|
||||
onClick={() => void issuePasswordSetupLink(editing, true)}
|
||||
>
|
||||
{t('copyPasswordSetupLink')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : editLoadingWorkingHours ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
toothJobRowsForDetail,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||
import {
|
||||
LabCaseShareQrDialog,
|
||||
LabCaseShareQrThumb,
|
||||
} from '@/components/ui/lab/LabCaseShareQrDialog';
|
||||
|
||||
interface LabCasesDispatchPanelProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
@@ -105,6 +109,7 @@ export function LabCasesDispatchPanel({
|
||||
const tErrors = useTranslations('errors');
|
||||
const [fetchedCatalog, setFetchedCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
const [shareQrOpen, setShareQrOpen] = useState(false);
|
||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
@@ -161,6 +166,7 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
setShareQrOpen(false);
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||
@@ -258,6 +264,7 @@ export function LabCasesDispatchPanel({
|
||||
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId)
|
||||
: [];
|
||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||
const shareUrl = activeLabCase?.shareUrl?.trim() ?? '';
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
|
||||
@@ -272,32 +279,44 @@ export function LabCasesDispatchPanel({
|
||||
{activeLabCase ? (
|
||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
|
||||
{sent ? (
|
||||
<>
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col-reverse items-stretch gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
{activeLabOrgName ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
|
||||
{toothJobRows.length > 0 ? (
|
||||
<LabCaseToothJobsList
|
||||
rows={toothJobRows}
|
||||
prosthesisCatalog={catalog}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaseSentLabel
|
||||
treatmentCase={{
|
||||
destinationOrganizationId: activeLabCase.destinationOrganizationId,
|
||||
sendToOrganizationIds: activeLabCase.destinationOrganizationId
|
||||
? [activeLabCase.destinationOrganizationId]
|
||||
: [],
|
||||
sentAt: activeLabCase.sentAt ?? null,
|
||||
sends: activeLabCase.sends,
|
||||
}}
|
||||
orgs={orgs}
|
||||
/>
|
||||
|
||||
{toothJobRows.length > 0 ? (
|
||||
<LabCaseToothJobsList
|
||||
rows={toothJobRows}
|
||||
prosthesisCatalog={catalog}
|
||||
/>
|
||||
) : null}
|
||||
{shareUrl ? (
|
||||
<div className="shrink-0 self-start">
|
||||
<LabCaseShareQrThumb
|
||||
shareUrl={shareUrl}
|
||||
onClick={() => setShareQrOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasTrackerSummary && labCaseSummary ? (
|
||||
<LabCaseTrackerCard
|
||||
@@ -317,7 +336,7 @@ export function LabCasesDispatchPanel({
|
||||
onActivityChange={onLabCaseActivityChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -430,6 +449,13 @@ export function LabCasesDispatchPanel({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{shareUrl ? (
|
||||
<LabCaseShareQrDialog
|
||||
open={shareQrOpen}
|
||||
onClose={() => setShareQrOpen(false)}
|
||||
shareUrl={shareUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function ProsthesisCatalogIcon({
|
||||
src,
|
||||
size = 'md',
|
||||
}: {
|
||||
src: string;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
const box = size === 'sm' ? 'h-8 w-8 sm:h-9 sm:w-9' : 'h-9 w-9 sm:h-10 sm:w-10';
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center justify-center ${box}`} aria-hidden>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
|
||||
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogIconSrc } from '@/components/treatment/prosthesisCatalogIcons';
|
||||
import {
|
||||
PROSTHESIS_JOB_PATH_SEP,
|
||||
prosthesisJobPathParts,
|
||||
} from '@/components/treatment/prosthesisJobPath';
|
||||
import {
|
||||
catalogByCode,
|
||||
categoryCodesInCatalog,
|
||||
@@ -23,14 +28,24 @@ import {
|
||||
type ArchTarget,
|
||||
type PickerScope,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import { ProsthesisCatalogIcon } from '@/components/ui/treatment/ProsthesisCatalogIcon';
|
||||
|
||||
const CHIP_INK = '#14253d';
|
||||
const PARENT_BACK_INK = '#000000';
|
||||
const TILE_CLASS =
|
||||
'flex min-h-[3.25rem] w-full flex-col items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
'flex min-h-[4.5rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const CHILD_TILE_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2 py-1 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const PARENT_BAR_CLASS =
|
||||
'flex min-h-[3.75rem] min-w-0 w-full flex-row items-center justify-start gap-2 rounded-[var(--radius-md)] border px-3 py-1.5 text-start transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45';
|
||||
const TILE_TEXT_CLASS =
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
|
||||
const TILE_TEXT_CHILD_CLASS =
|
||||
'w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-center text-[11px] font-medium leading-tight sm:text-xs';
|
||||
const TILE_GRID_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]';
|
||||
const TILE_GRID_CHILD_CLASS =
|
||||
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(10.2rem,1fr))]';
|
||||
const HEADER_BTN_CLASS = 'h-auto min-h-8 w-full px-2 py-1 text-xs leading-tight whitespace-normal';
|
||||
|
||||
function tileSurfaceClass(active?: boolean) {
|
||||
@@ -70,7 +85,9 @@ function Tile({
|
||||
active,
|
||||
disabled,
|
||||
hint,
|
||||
className = TILE_CLASS,
|
||||
iconSrc,
|
||||
compact,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -78,19 +95,23 @@ function Tile({
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
iconSrc?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = className ?? (compact ? CHILD_TILE_CLASS : TILE_CLASS);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={hint ?? label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${className} ${tileSurfaceClass(active)}`}
|
||||
className={`${base} ${compact ? 'gap-0.5' : 'gap-2'} ${tileSurfaceClass(active)}`}
|
||||
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
|
||||
>
|
||||
<span className={TILE_TEXT_CLASS}>{label}</span>
|
||||
{iconSrc ? <ProsthesisCatalogIcon src={iconSrc} /> : null}
|
||||
<span className={compact ? TILE_TEXT_CHILD_CLASS : TILE_TEXT_CLASS}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +130,19 @@ function parentStep(step: PickerStep): PickerStep | null {
|
||||
}
|
||||
}
|
||||
|
||||
function pickerStepKey(step: PickerStep): string {
|
||||
switch (step.kind) {
|
||||
case 'category':
|
||||
return 'category';
|
||||
case 'addon':
|
||||
return `addon:${step.slot}`;
|
||||
case 'subcategory':
|
||||
return `sub:${step.category}:${step.arch ?? ''}`;
|
||||
case 'leaves':
|
||||
return `leaves:${step.category}:${step.subcategory ?? ''}:${step.arch ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProsthesisJobPopover({
|
||||
open,
|
||||
mobile,
|
||||
@@ -128,6 +162,31 @@ export function ProsthesisJobPopover({
|
||||
onClose,
|
||||
}: ProsthesisJobPopoverProps) {
|
||||
const t = useTranslations('prosthesis');
|
||||
const childGridRef = useRef<HTMLDivElement>(null);
|
||||
const [branchH, setBranchH] = useState(0);
|
||||
const stepKey = pickerStepKey(step);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || stepKey === 'category') return;
|
||||
const grid = childGridRef.current;
|
||||
if (!grid) return;
|
||||
|
||||
const sync = () => {
|
||||
const first = grid.firstElementChild;
|
||||
if (!(first instanceof HTMLElement)) return;
|
||||
setBranchH(first.offsetHeight);
|
||||
};
|
||||
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(grid);
|
||||
grid.addEventListener('animationend', sync);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
grid.removeEventListener('animationend', sync);
|
||||
};
|
||||
}, [open, stepKey]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const categories = categoryCodesInCatalog(catalog, scope);
|
||||
@@ -159,10 +218,15 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
|
||||
function leafTile(leaf: ProsthesisCatalogEntry) {
|
||||
const path = prosthesisJobPathParts(leaf.code, catalog, (key) => t(key as never));
|
||||
const underIndirectSub =
|
||||
leaf.category === 'indirect' && step.kind === 'leaves' && Boolean(step.subcategory);
|
||||
return (
|
||||
<Tile
|
||||
key={leaf.code}
|
||||
label={leaf.label}
|
||||
label={underIndirectSub ? path.leaf : leaf.label}
|
||||
compact
|
||||
iconSrc={prosthesisCatalogIconSrc(leaf)}
|
||||
color={prosthesisTypeColorFromCatalog(leaf.code, catalog)}
|
||||
active={jobCodes.includes(leaf.code)}
|
||||
onClick={() => onPickLeaf(leaf.code, archForLeaves)}
|
||||
@@ -180,7 +244,11 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key="slot-crown"
|
||||
className={`${TILE_CLASS} h-full`}
|
||||
compact
|
||||
label={byCode.get(currentCrown)?.label ?? currentCrown}
|
||||
iconSrc={prosthesisCatalogIconSrc(
|
||||
byCode.get(currentCrown) ?? { code: currentCrown, category: 'crown' },
|
||||
)}
|
||||
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
|
||||
active={addonActive}
|
||||
onClick={openAddon}
|
||||
@@ -207,7 +275,7 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
}
|
||||
|
||||
let selectedNode: { label: string; color?: string } | null = null;
|
||||
let selectedNode: { label: string; color?: string; iconSrc?: string } | null = null;
|
||||
let childTiles: ReactNode[] = [];
|
||||
let childrenKey = 'root';
|
||||
|
||||
@@ -219,6 +287,7 @@ export function ProsthesisJobPopover({
|
||||
<Tile
|
||||
key={code}
|
||||
label={categoryLabel(code)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: code })}
|
||||
color={categoryTileColor(code)}
|
||||
disabled={disabled}
|
||||
hint={disabled ? t('categoryDisabledHint') : undefined}
|
||||
@@ -228,11 +297,16 @@ export function ProsthesisJobPopover({
|
||||
});
|
||||
break;
|
||||
case 'addon':
|
||||
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
|
||||
selectedNode = {
|
||||
label: t('slotCrown'),
|
||||
color: categoryTileColor('crown'),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: 'crown' }),
|
||||
};
|
||||
childrenKey = 'addon-crown';
|
||||
childTiles = [
|
||||
<Tile
|
||||
key="none"
|
||||
compact
|
||||
label={t('addonNone')}
|
||||
active={!currentCrown}
|
||||
onClick={() => onPickAddon(null, 'crown')}
|
||||
@@ -240,7 +314,9 @@ export function ProsthesisJobPopover({
|
||||
...crownLeaves.map((opt) => (
|
||||
<Tile
|
||||
key={opt.code}
|
||||
compact
|
||||
label={opt.label}
|
||||
iconSrc={prosthesisCatalogIconSrc(opt)}
|
||||
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
||||
active={currentCrown === opt.code}
|
||||
onClick={() => onPickAddon(opt.code, 'crown')}
|
||||
@@ -250,13 +326,19 @@ export function ProsthesisJobPopover({
|
||||
break;
|
||||
case 'subcategory': {
|
||||
const parentFill = categoryTileColor(step.category);
|
||||
selectedNode = { label: categoryLabel(step.category), color: parentFill };
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: parentFill,
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `sub-${step.category}`;
|
||||
childTiles = [
|
||||
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
|
||||
<Tile
|
||||
key={sub}
|
||||
compact
|
||||
label={subLabel(sub)}
|
||||
iconSrc={prosthesisCatalogIconSrc({ category: step.category, subcategory: sub })}
|
||||
color={prosthesisFamilyTone(parentFill, i + 1)}
|
||||
onClick={() =>
|
||||
onStep({
|
||||
@@ -277,6 +359,10 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: subLabel(step.subcategory),
|
||||
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
|
||||
iconSrc: prosthesisCatalogIconSrc({
|
||||
category: step.category,
|
||||
subcategory: step.subcategory,
|
||||
}),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}-${step.subcategory}`;
|
||||
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
|
||||
@@ -284,6 +370,7 @@ export function ProsthesisJobPopover({
|
||||
selectedNode = {
|
||||
label: categoryLabel(step.category),
|
||||
color: categoryTileColor(step.category),
|
||||
iconSrc: prosthesisCatalogIconSrc({ category: step.category }),
|
||||
};
|
||||
childrenKey = `leaves-${step.category}`;
|
||||
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
|
||||
@@ -294,9 +381,10 @@ export function ProsthesisJobPopover({
|
||||
const tileGrid = (
|
||||
<div
|
||||
key={childrenKey}
|
||||
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
|
||||
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
|
||||
}`}
|
||||
ref={childGridRef}
|
||||
className={`picker-expand min-w-0 flex-1 ${
|
||||
selectedNode ? TILE_GRID_CHILD_CLASS : TILE_GRID_CLASS
|
||||
}${selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(4.5rem,1fr)]'}`}
|
||||
>
|
||||
{childTiles}
|
||||
</div>
|
||||
@@ -311,7 +399,7 @@ export function ProsthesisJobPopover({
|
||||
if (back) onStep(back);
|
||||
}}
|
||||
disabled={!back}
|
||||
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
|
||||
className={PARENT_BAR_CLASS}
|
||||
style={
|
||||
selectedNode.color
|
||||
? { backgroundColor: selectedNode.color, color: CHIP_INK }
|
||||
@@ -320,18 +408,19 @@ export function ProsthesisJobPopover({
|
||||
aria-label={t('pickerBack')}
|
||||
>
|
||||
<ArrowLeft
|
||||
className="lucide-inherit h-4 w-4 shrink-0 rtl:rotate-180"
|
||||
style={
|
||||
selectedNode.color
|
||||
? { color: prosthesisTypeFillAccent(selectedNode.color) }
|
||||
: undefined
|
||||
}
|
||||
className="h-4 w-4 shrink-0 rtl:rotate-180"
|
||||
style={{ color: PARENT_BACK_INK }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
|
||||
{selectedNode.iconSrc ? (
|
||||
<ProsthesisCatalogIcon src={selectedNode.iconSrc} size="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 whitespace-normal break-words text-start text-xs font-medium leading-tight sm:text-[13px]">
|
||||
{selectedNode.label}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-start">
|
||||
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
|
||||
<div className="relative w-3 shrink-0" style={{ height: branchH || undefined }} aria-hidden>
|
||||
<span
|
||||
className="absolute w-0.5"
|
||||
style={{
|
||||
@@ -375,7 +464,7 @@ export function ProsthesisJobPopover({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{jobCodes.map((code) => {
|
||||
const entry = byCode.get(code);
|
||||
const path = prosthesisJobPathParts(code, catalog, (key) => t(key as never));
|
||||
const color = prosthesisTypeColorFromCatalog(code, catalog);
|
||||
const trashColor = prosthesisTypeFillAccent(color);
|
||||
return (
|
||||
@@ -388,13 +477,14 @@ export function ProsthesisJobPopover({
|
||||
borderColor: 'rgba(0,0,0,0.16)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5">
|
||||
<span className="opacity-70">
|
||||
{entry?.category
|
||||
? t(`category_${entry.category}` as never)
|
||||
: t('regionCrown')}
|
||||
</span>
|
||||
{entry?.label ?? code}
|
||||
<span className="inline-flex items-center px-2 py-0.5">
|
||||
{path.ancestors.length > 0 ? (
|
||||
<span className="opacity-70">
|
||||
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
|
||||
{PROSTHESIS_JOB_PATH_SEP}
|
||||
</span>
|
||||
) : null}
|
||||
{path.leaf}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -418,8 +508,8 @@ export function ProsthesisJobPopover({
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr] gap-x-3">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-2.5">
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr]">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-2.5 border-e border-border pe-3">
|
||||
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
|
||||
{scope === 'arch' && onLockedArchChange ? (
|
||||
<div
|
||||
@@ -451,7 +541,7 @@ export function ProsthesisJobPopover({
|
||||
{tileRow}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<section className="flex min-h-0 min-w-0 flex-col gap-1 ps-3">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
isLabDependentDetailMissingTeeth,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
|
||||
const WORKSPACE_ACTION_BTN_HEIGHT = 'h-[2.875rem] sm:h-9';
|
||||
const WORKSPACE_ACTION_BTN_CLASS = `${WORKSPACE_ACTION_BTN_HEIGHT} shrink-0 px-3 text-sm`;
|
||||
|
||||
interface TreatmentDetailsEditorProps {
|
||||
details: TreatmentDetailDraft[];
|
||||
activeDetailId: string;
|
||||
@@ -116,6 +119,8 @@ export function TreatmentDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
const actionWidthLabels = [t('showLabDispatch'), t('showChart'), t('addDetail')];
|
||||
|
||||
return (
|
||||
<div className="surface-card p-3 sm:p-4 space-y-4">
|
||||
{showChrome ? (
|
||||
@@ -127,6 +132,7 @@ export function TreatmentDetailsEditor({
|
||||
{voice ? (
|
||||
<AddDetailWithVoice
|
||||
addLabel={t('addDetail')}
|
||||
widthLabels={actionWidthLabels}
|
||||
startLabel={t('voiceStart')}
|
||||
stopLabel={t('voiceStop')}
|
||||
disabled={!canEdit || disabled}
|
||||
@@ -137,12 +143,12 @@ export function TreatmentDetailsEditor({
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
className={WORKSPACE_ACTION_BTN_CLASS}
|
||||
>
|
||||
{t('addDetail')}
|
||||
<WorkspaceActionLabel label={t('addDetail')} widthLabels={actionWidthLabels} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -263,7 +269,8 @@ export function TreatmentDetailsEditor({
|
||||
<Button
|
||||
type="button"
|
||||
variant={labWorkspaceView === 'dispatch' ? 'outline' : 'primary'}
|
||||
className={`h-[2.875rem] sm:h-9 shrink-0 px-3 text-sm ${
|
||||
size="sm"
|
||||
className={`${WORKSPACE_ACTION_BTN_CLASS} ${
|
||||
labWorkspaceView === 'dispatch' ? 'bg-background-secondary' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
@@ -272,17 +279,10 @@ export function TreatmentDetailsEditor({
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="inline-grid justify-items-center">
|
||||
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
|
||||
{t('showLabDispatch')}
|
||||
</span>
|
||||
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
|
||||
{t('showChart')}
|
||||
</span>
|
||||
<span className="col-start-1 row-start-1 whitespace-nowrap">
|
||||
{labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
|
||||
</span>
|
||||
</span>
|
||||
<WorkspaceActionLabel
|
||||
label={labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
|
||||
widthLabels={actionWidthLabels}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -327,6 +327,29 @@ export function TreatmentDetailsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceActionLabel({
|
||||
label,
|
||||
widthLabels,
|
||||
}: {
|
||||
label: string;
|
||||
widthLabels: string[];
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-grid justify-items-center">
|
||||
{widthLabels.map((text, index) => (
|
||||
<span
|
||||
key={`${index}-${text}`}
|
||||
className="invisible col-start-1 row-start-1 whitespace-nowrap"
|
||||
aria-hidden
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
<span className="col-start-1 row-start-1 whitespace-nowrap">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesField({
|
||||
textareaRef,
|
||||
value,
|
||||
@@ -377,6 +400,7 @@ function NotesField({
|
||||
*/
|
||||
function AddDetailWithVoice({
|
||||
addLabel,
|
||||
widthLabels,
|
||||
startLabel,
|
||||
stopLabel,
|
||||
disabled,
|
||||
@@ -384,6 +408,7 @@ function AddDetailWithVoice({
|
||||
voice,
|
||||
}: {
|
||||
addLabel: string;
|
||||
widthLabels: string[];
|
||||
startLabel: string;
|
||||
stopLabel: string;
|
||||
disabled: boolean;
|
||||
@@ -397,8 +422,8 @@ function AddDetailWithVoice({
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 sm:w-auto
|
||||
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 ${WORKSPACE_ACTION_BTN_HEIGHT}
|
||||
${disabled ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
@@ -407,13 +432,13 @@ function AddDetailWithVoice({
|
||||
onClick={onAddDetail}
|
||||
disabled={disabled || isBusy}
|
||||
className="
|
||||
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
||||
px-3 text-sm font-medium transition-all duration-200
|
||||
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
||||
focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
||||
"
|
||||
>
|
||||
{addLabel}
|
||||
<WorkspaceActionLabel label={addLabel} widthLabels={widthLabels} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -295,6 +295,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
sends: lc.sends ?? [],
|
||||
dueDate: lc.dueDate ?? null,
|
||||
taskProgress: lc.taskProgress ?? null,
|
||||
shareUrl: lc.shareUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,7 +397,7 @@ function mergeServerIdsIntoLabCases(
|
||||
return local.map((lc) => {
|
||||
const s = byClientId.get(lc.clientId);
|
||||
if (!s) return lc;
|
||||
return { ...lc, id: s.id ?? lc.id };
|
||||
return { ...lc, id: s.id ?? lc.id, shareUrl: s.shareUrl ?? lc.shareUrl };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2541,6 +2542,7 @@ export function TreatmentWorkspace({
|
||||
destinationOrganizationId: response.data.destinationOrganizationId,
|
||||
sends: response.data.sends,
|
||||
taskProgress: response.data.taskProgress ?? lc.taskProgress,
|
||||
shareUrl: response.data.shareUrl ?? lc.shareUrl,
|
||||
}
|
||||
: lc,
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface StaffMemberDto {
|
||||
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
|
||||
invitedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
hasPassword: boolean;
|
||||
permissions: string[] | null;
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ export interface PreviewInviteResponse {
|
||||
organizationName: string;
|
||||
expiresAt: string;
|
||||
status: 'PENDING' | 'ACCEPTED';
|
||||
mode: 'join' | 'password_setup';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +81,21 @@ export const staffApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
clearPassword: async (
|
||||
membershipId: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
membershipId: string;
|
||||
invitationId: string;
|
||||
email: string;
|
||||
invitationUrl: string;
|
||||
};
|
||||
}> => {
|
||||
const response = await apiClient.post(`/staff/members/${membershipId}/clear-password`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
previewInvite: async (token: string): Promise<PreviewInviteResponse> => {
|
||||
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
|
||||
return response.data;
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface PastLabCase {
|
||||
dueDate?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
attachments?: TreatmentAttachmentMeta[];
|
||||
/** Present on sent cases — same share URL the lab Cases QR encodes. */
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface PastTreatment {
|
||||
@@ -183,6 +185,7 @@ export interface LabCaseDraft {
|
||||
sends?: LabCaseSendInfo[];
|
||||
dueDate?: string | null;
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
export type SavedTreatmentDetailPayload = {
|
||||
@@ -237,4 +240,5 @@ export interface LabCaseResponse {
|
||||
taskProgress?: LabCaseTaskProgress | null;
|
||||
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
||||
attachments?: TreatmentAttachmentMeta[];
|
||||
shareUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@ On the Windows host, from repo `infrastructure/`:
|
||||
| Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` |
|
||||
| Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` |
|
||||
| `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). |
|
||||
| `TLS handshake timeout` to `registry-1.docker.io` / `node:20-alpine` | Docker Hub is blocked or slow from the Windows runner. CI pulls `node:20-alpine` from **Arvan / ECR Public / GCR**, then pushes `<REGISTRY_PREFIX>/node:20-alpine` to Gitea (later builds skip Hub). Optional variable `NODE_IMAGE_SOURCE` (comma-separated image refs). One-time on the runner: `docker pull docker.arvancloud.ir/library/node:20-alpine` then tag/push to Gitea. |
|
||||
| `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. |
|
||||
| `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). |
|
||||
| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker |
|
||||
@@ -330,6 +331,7 @@ docker logs dyolink_frontend_staging --tail 50
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy |
|
||||
| `infrastructure/scripts/ci-resolve-node-image.ps1` | CI: cache `node:20-alpine` on Gitea so builds do not depend on Docker Hub |
|
||||
| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) |
|
||||
| `infrastructure/deploy.registry.env.example` | Manual deploy env template |
|
||||
| `infrastructure/database.staging.env.example` | Postgres secrets template |
|
||||
|
||||
100
infrastructure/scripts/ci-resolve-node-image.ps1
Normal file
@@ -0,0 +1,100 @@
|
||||
# Prefer a Gitea-hosted node:20-alpine so docker build does not HEAD registry-1.docker.io.
|
||||
# Order: Gitea -> optional NODE_IMAGE_SOURCE -> regional/official mirrors -> Docker Hub last.
|
||||
# ASCII only: Windows PowerShell 5.1 + act_runner mis-parses backtick escapes in this file.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RegistryPrefix,
|
||||
[string]$OutFile = '.ci-node-image',
|
||||
[string]$HubImage = 'node:20-alpine',
|
||||
[AllowEmptyString()]
|
||||
[string]$ExtraSources = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$mirror = "$RegistryPrefix/node:20-alpine"
|
||||
$nl = [char]10
|
||||
|
||||
function Test-Image([string]$Name) {
|
||||
docker image inspect $Name 2>&1 | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Invoke-Pull([string]$Name, [int]$Attempts) {
|
||||
for ($i = 1; $i -le $Attempts; $i++) {
|
||||
Write-Host "docker pull $Name (attempt $i/$Attempts)"
|
||||
docker pull $Name
|
||||
if ($LASTEXITCODE -eq 0) { return $true }
|
||||
if ($i -lt $Attempts) { Start-Sleep -Seconds 5 }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Save-Choice([string]$Name) {
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
$path = Join-Path (Get-Location) $OutFile
|
||||
[System.IO.File]::WriteAllText($path, ($Name + $nl), $utf8)
|
||||
Write-Host "NODE_IMAGE=$Name"
|
||||
}
|
||||
|
||||
function Publish-Mirror([string]$Src) {
|
||||
Write-Host "Tagging $Src as $mirror"
|
||||
docker tag $Src $mirror
|
||||
if ($LASTEXITCODE -ne 0) { return $false }
|
||||
docker push $mirror
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Could not push $mirror - docker build will use the local tag (legacy builder)."
|
||||
$flag = Join-Path (Get-Location) '.ci-use-legacy-builder'
|
||||
New-Item -ItemType File -Path $flag -Force | Out-Null
|
||||
}
|
||||
Save-Choice $mirror
|
||||
return $true
|
||||
}
|
||||
|
||||
if (Test-Image $mirror) {
|
||||
Write-Host "Using local $mirror"
|
||||
Save-Choice $mirror
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Invoke-Pull $mirror 1) {
|
||||
Save-Choice $mirror
|
||||
exit 0
|
||||
}
|
||||
|
||||
$sources = New-Object System.Collections.ArrayList
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExtraSources)) {
|
||||
foreach ($part in ($ExtraSources -split ',')) {
|
||||
$src = $part.Trim()
|
||||
if ($src.Length -gt 0) { [void]$sources.Add($src) }
|
||||
}
|
||||
}
|
||||
# Iran-reachable proxy of Docker Hub official images, then public official mirrors, Hub last.
|
||||
foreach ($src in @(
|
||||
'docker.arvancloud.ir/library/node:20-alpine',
|
||||
'public.ecr.aws/docker/library/node:20-alpine',
|
||||
'mirror.gcr.io/library/node:20-alpine',
|
||||
$HubImage
|
||||
)) {
|
||||
if (-not $sources.Contains($src)) { [void]$sources.Add($src) }
|
||||
}
|
||||
|
||||
foreach ($src in $sources) {
|
||||
if (Test-Image $src) {
|
||||
Write-Host "Found local $src"
|
||||
if (Publish-Mirror $src) { exit 0 }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($src in $sources) {
|
||||
if (Invoke-Pull $src 2) {
|
||||
if (Publish-Mirror $src) { exit 0 }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Could not pull node:20-alpine from Gitea, mirrors, or Docker Hub."
|
||||
Write-Host "Set repository variable NODE_IMAGE_SOURCE to a reachable image, for example:"
|
||||
Write-Host " docker.arvancloud.ir/library/node:20-alpine"
|
||||
Write-Host "Or on the Windows runner:"
|
||||
Write-Host " docker pull docker.arvancloud.ir/library/node:20-alpine"
|
||||
Write-Host " docker tag docker.arvancloud.ir/library/node:20-alpine $mirror"
|
||||
Write-Host " docker push $mirror"
|
||||
exit 1
|
||||