Compare commits

..

13 Commits

Author SHA1 Message Date
19b5671b0d improvement: Frontend Dockerfile updated so that aseests in public folder loads on the website.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m51s
Production — tag build, push, deploy / deploy (push) Successful in 2m47s
2026-09-06 14:20:28 +03:30
9f6eacd0fb Merge branch 'master' into improvements/subscription-v1
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m22s
Production — tag build, push, deploy / deploy (push) Successful in 17m31s
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
108 changed files with 1904 additions and 326 deletions

View File

@@ -7,6 +7,7 @@ alwaysApply: false
# Appointments
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
- **Patient:** create/update must use a **named** patient of this org (`createdByOrganizationId`, not walk-in). Helper `ensurePatientInOrg`. Do not book another clinics patient UUID.
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).

View File

@@ -11,6 +11,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **CLINIC** orgs: patients, appointments, treatment, staff.
- **LAB** orgs: cases, tasks, lab workflows.
- Named **patients** are scoped to `createdByOrganizationId`. `mobile` stays globally unique — other-org / walk-in hit `PATIENT_MOBILE_UNAVAILABLE` (no shared row).
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
## Agent behavior

View File

@@ -1,6 +1,6 @@
---
description: Brand and FDI SVG sources live under frontend/src/assets, not public/
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**,frontend/Dockerfile
alwaysApply: false
---
@@ -9,4 +9,6 @@ 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`.
- **Docker:** Next `output: 'standalone'` does **not** include `public/`. `frontend/Dockerfile` must `COPY` builder `/app/public` to `./public` next to `server.js` (after the standalone copy). Missing this 404s `/prosthesis-catalog/*.svg` in staging/prod.

View File

@@ -1,18 +1,19 @@
---
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.
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites: `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`

View File

@@ -1,6 +1,6 @@
---
description: Lab Cases tab — prosthesis filter, auto-select, list cards, 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

@@ -0,0 +1,12 @@
---
description: Clinic patients — org-scoped named records, globally unique mobile
globs: backend/src/modules/patients/**,backend/src/modules/appointments/appointments.service.ts,backend/src/modules/treatments/treatments.service.ts,frontend/src/components/ui/patient/**,frontend/src/lib/api/patients.ts
alwaysApply: false
---
# Patients
- List / get / update / create: **this org** + `isWalkIn: false` (`createdByOrganizationId`).
- `Patient.mobile` stays **globally unique**. Same-org named create returns `{ existing: true }`. Other org, walk-in, or null creator → `PATIENT_MOBILE_UNAVAILABLE` (409). Do **not** return or mention the other clinics row.
- Appointment create/update and `POST /treatments` named `patientId`: `ensurePatientInOrg` (named + this org). Walk-in sentinel is per clinic (`walk-in-patient.ts`), hidden from Patients/search/booking.
- History / lab-case lists still query treatments for **this** `organizationId` even if the patient UUID is guessed.

View File

@@ -10,7 +10,7 @@ alwaysApply: false
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. ❌ Do not put the hook on invite pages (logged-in visitors must finish accept).
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. Same for `/accept-invite` `mode: password_setup` (password fields only). ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). Login does not special-case `passwordHash: null` — those users cannot sign in until they set a password via the setup link.
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.

13
.cursor/rules/staff.mdc Normal file
View File

@@ -0,0 +1,13 @@
---
description: Staff passwords — setup link only; never set another users password
globs: backend/src/modules/staff/**,frontend/src/components/ui/staff/**,frontend/src/app/**/accept-invite/**,frontend/src/lib/api/staff.ts
alwaysApply: false
---
# Staff passwords
- Owner / `TAB_STAFF_EDIT` may **clear** a password, never set one for someone else.
- `POST /staff/members/:membershipId/clear-password`: `passwordHash: null`, delete sessions, revoke unused invites on that membership, mint a 7-day `/accept-invite` URL. Refuse owner, self, pending, disabled (`STAFF_CANNOT_CLEAR_OWN_PASSWORD`, `STAFF_PASSWORD_CLEAR_ACTIVE_ONLY`).
- List DTO: `hasPassword` boolean only (never the hash). `previewInvite` `mode`: `join` | `password_setup` from `membership.isActive`.
- Login page unchanged — null hash is invalid credentials until they set a password on the setup link.
- `/accept-invite` `password_setup`: password fields only; then `login()` + `navigateIntoAppIfOrgSelected` (no enter-app hook).

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)
@@ -54,7 +56,7 @@ Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite: `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
5. Multi-org: redirect stays in storage until `selectOrganization()``appPathAfterAuth()`.
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.

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`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). 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 }`.
@@ -188,7 +188,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` |
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
@@ -202,7 +202,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinics mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

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):**
@@ -62,17 +62,21 @@ frontend/src/
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**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`.
**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). Patient must belong to this org (`ensurePatientInOrg`). 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`.
**Patients (quick ref):** List/get/update/create are this-org named patients (`createdByOrganizationId`, `isWalkIn: false`). Mobile stays globally unique. Same-org mobile create returns `existing: true`; other org / walk-in / null creator → `PATIENT_MOBILE_UNAVAILABLE` (409, no leak). See `.cursor/rules/patients.mdc`.
**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`.
**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
**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. 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.
- 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`.
- **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 (including `password_setup`) 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

@@ -106,4 +106,4 @@ Jest covers pure logic only — permission normalization, phone/timezone helpers
## Deployment
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image.
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.

View File

@@ -33,7 +33,7 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
| Path | Role |
|------|------|
| `backend/Dockerfile` | API image |
| `frontend/Dockerfile` | Web image |
| `frontend/Dockerfile` | Web image (`standalone` + copy `public/` for catalog icons) |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |

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
@@ -47,6 +50,8 @@ ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
# Standalone does not include public/; catalog icons are <img src="/prosthesis-catalog/*.svg">.
COPY --from=builder --chown=dyolink:nodejs /app/public ./public
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").

View File

@@ -60,4 +60,4 @@ npm install
## Docker
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Next `standalone` does not include `public/` — `frontend/Dockerfile` copies it so `/prosthesis-catalog/*.svg` is served in production.

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",
@@ -156,9 +159,8 @@
"verifyFailed": "Invalid or expired verification code."
},
"landing": {
"heroTitle": "Connect Dental Clinics & Labs",
"heroHighlight": "Seamlessly",
"heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.",
"heroTitle": "Nudentic is a digital workflow platform for modern dentistry.",
"heroSubtitle": "It brings clinical and laboratory workflows, case information, communication, and patient records into one structured environment.",
"featureClinicsTitle": "For Clinics",
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
"featureLabsTitle": "For Labs",
@@ -322,6 +324,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 +1149,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 +1228,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 +1252,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": "پذیرش دعوتنامه امکان‌پذیر نبود",
@@ -156,9 +159,8 @@
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
},
"landing": {
"heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی",
"heroHighlight": "به‌صورت یکپارچه",
"heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.",
"heroTitle": "Nudentic یک پلتفرم گردش‌کار دیجیتال برای دندانپزشکی مدرن است.",
"heroSubtitle": "این پلتفرم گردش‌کارهای بالینی و لابراتواری، اطلاعات پرونده، ارتباط و سوابق بیمار را در یک محیط ساختاریافته کنار هم می‌آورد.",
"featureClinicsTitle": "برای کلینیک‌ها",
"featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.",
"featureLabsTitle": "برای لابراتوارها",
@@ -322,6 +324,18 @@
"disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.",
"disableMemberButton": "غیرفعال‌سازی عضو",
"editModalTitle": "ویرایش عضو",
"removePassword": "حذف رمز عبور",
"copyPasswordSetupLink": "کپی لینک تنظیم رمز",
"removePasswordModalTitle": "حذف رمز عبور",
"removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟",
"removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمی‌توانند وارد شوند.",
"removePasswordBullet2": "شما رمز جدید را انتخاب نمی‌کنید. لینک تنظیم را برایشان بفرستید.",
"removePasswordBullet3": "از همه سازمان‌هایی که عضو آن هستند خارج می‌شوند.",
"removePasswordButton": "حذف رمز و کپی لینک",
"passwordSetupLinkHeading": "لینک تنظیم رمز عبور",
"passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.",
"successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.",
"errorClearPassword": "حذف رمز عبور امکان‌پذیر نبود.",
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
"errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.",
@@ -1136,7 +1150,6 @@
"noneYet": "هنوز هیچ",
"clearHint": "«پاک کردن این دندان» آن را از کیس حذف می‌کند. با × روی تراشه یک نوع را بردارید.",
"removeJob": "حذف این نوع",
"regionCrown": "تاج",
"pickerTooth": "دندان {tooth}",
"slotCrown": "روکش",
"suggestionsLabel": "افزودنی‌های پیشنهادی",
@@ -1216,6 +1229,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 +1253,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",
@@ -156,9 +159,8 @@
"verifyFailed": "Ongeldige of verlopen verificatiecode."
},
"landing": {
"heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria",
"heroHighlight": "Naadloos",
"heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.",
"heroTitle": "Nudentic is een digitaal workflowplatform voor de moderne tandheelkunde.",
"heroSubtitle": "Het brengt klinische en laboratoriumworkflows, casusinformatie, communicatie en patiëntendossiers samen in één gestructureerde omgeving.",
"featureClinicsTitle": "Voor Klinieken",
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
"featureLabsTitle": "Voor Laboratoria",
@@ -322,6 +324,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 +1149,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 +1228,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 +1252,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>
)}

View File

@@ -52,12 +52,11 @@ export default function HomePage() {
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-semibold mb-4 sm:mb-6 leading-tight text-balance max-w-3xl mx-auto">
{t('heroTitle')}
<span className="text-primary"> {t('heroHighlight')}</span>
</h1>
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-2xl mx-auto">
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-3xl mx-auto">
{t('heroSubtitle')}
</p>

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

Some files were not shown because too many files have changed in this diff Show More