Compare commits

...

12 Commits

Author SHA1 Message Date
9f6eacd0fb Merge branch 'master' into improvements/subscription-v1 2026-09-05 23:07:49 +03:30
52c4480c5d Merge pull request 'Fix Ci/Cd: Get Node another way — try Gitea cache, then Arvan / AWS / GCR, and only then Docker Hub; once pulled, store it on Gitea for next time.' (#74) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 43s
Registry — build, push, deploy / deploy (push) Successful in 1m25s
Reviewed-on: #74
Reviewed-by: admin <admin@localhost>
2026-09-05 23:00:29 +03:30
1ca8e6178e improvement: Nothing done is related to subscriptions!!! 2026-09-05 20:44:16 +03:30
6c7e0574b9 fix: omit ExtraSources when NODE_IMAGE_SOURCE is empty
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 5m17s
Production — tag build, push, deploy / deploy (push) Successful in 13m34s
2026-09-05 20:18:06 +03:30
feafc3a219 fix: pass empty ExtraSources when NODE_IMAGE_SOURCE is unset 2026-09-05 20:11:18 +03:30
8fa856a980 fix: pull node:20-alpine from Arvan/ECR when Docker Hub is blocked 2026-09-05 19:59:45 +03:30
005e0b0394 fix: parse ci-resolve-node-image.ps1 on Windows PowerShell 5.1 2026-09-05 19:48:46 +03:30
c1846f8e22 fix: mirror node:20-alpine to Gitea when Docker Hub TLS times out
BuildKit was failing on registry-1.docker.io even when a local base
image existed. CI now prefers a Gitea-hosted NODE_IMAGE and retries
docker build.
2026-09-05 19:12:37 +03:30
663eafa3e8 fix: fail Gitea deploy when Docker image push does not succeed
Windows PowerShell ignored docker exit codes, so build-and-push looked
green while :sha tags were never pushed and deploy pulled missing images.
2026-09-05 19:03:06 +03:30
5cd3436d0c Merge pull request 'improvement: icons added to prosthesis types catalog.' (#73) from improvements/prosthesis-catalog-icons into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 41s
Registry — build, push, deploy / deploy (push) Failing after 10s
Reviewed-on: #73
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-09-05 13:45:25 +03:30
5d3597f973 improvement: icons added to prosthesis types catalog.
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Failing after 23s
Production — tag build, push, deploy / deploy (push) Has been skipped
2026-09-05 01:29:53 +03:30
4d7a4b390f Merge pull request 'improvement/prosthesis-types-overhaul' (#72) from improvement/prosthesis-types-overhaul into master
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Successful in 4m18s
Production — tag build, push, deploy / deploy (push) Successful in 12m48s
Registry — build, push, deploy / build-and-push (push) Successful in 36s
Registry — build, push, deploy / deploy (push) Failing after 11s
Reviewed-on: #72
Reviewed-by: rameen <rameen.naghdi@gmail.com>
2026-09-04 20:23:58 +03:30
98 changed files with 1847 additions and 301 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 details 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 todays 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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 }`.

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 cards 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 details 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 1121 / 4131 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 tooths 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`.

View File

@@ -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

View File

@@ -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',

View File

@@ -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);
}
}

View File

@@ -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 clinics 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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({

View File

@@ -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),
},
};
}

View File

@@ -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,
);
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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.",

View File

@@ -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": "سازمان مقابل اشتراک فعال ندارد.",

View File

@@ -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.",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View 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>
)}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View 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;
}

View 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(' + ');
}

View File

@@ -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(

View File

@@ -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(' + ');
}

View File

@@ -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, {

View File

@@ -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(',')}`}

View File

@@ -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(

View File

@@ -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 ? (

View File

@@ -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>
);
}

View File

@@ -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) })}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,
),

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 |

View 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