diff --git a/.cursor/rules/api-errors-i18n.mdc b/.cursor/rules/api-errors-i18n.mdc index 713e1f6..f5894ce 100644 --- a/.cursor/rules/api-errors-i18n.mdc +++ b/.cursor/rules/api-errors-i18n.mdc @@ -24,5 +24,6 @@ Frontend maps `details[].code` through the `errors` namespace. ## Do not +- Throw Nest `BadRequestException('English sentence')` for user-facing errors. - Show raw `error.message` or stack traces to users. - Add English-only strings inline in components. diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc index ee9abdf..96fecf1 100644 --- a/.cursor/rules/appointments.mdc +++ b/.cursor/rules/appointments.mdc @@ -10,5 +10,7 @@ alwaysApply: false - **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). +- **Working hours / create:** interpret wall-clock in the **client IANA time zone** (`timeZone` on create/update, e.g. `Asia/Tehran`). Do not use Node `Date#getHours()` / `getDay()` on the server (UTC Docker vs clinic local). Helper: `zoned-civil-time.ts`. - **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`). +- **Header date:** `ScheduleDayPicker` `compact` centered in a 3-col page header (same pattern as Treatment `AppointmentsStrip`). - Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index d3d47c2..9f32ca5 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -24,7 +24,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**. -Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native ``); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Appointments: `ScheduleDayPicker`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`. +Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native ``); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Treatment/Appointments headers: `ScheduleDayPicker` `compact`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`. ## Treatment / appointment colors @@ -36,7 +36,11 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo ## Lab case share link -QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`. +QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts` + `useEnterAppWhenAuthenticated` (not inside `login()`). Skill: `.cursor/skills/lab-case-share-link/SKILL.md`. + +## API errors + +Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`). ## Notifications (inbox + live tabs) diff --git a/.cursor/rules/frontend-assets.mdc b/.cursor/rules/frontend-assets.mdc new file mode 100644 index 0000000..ac5ebbf --- /dev/null +++ b/.cursor/rules/frontend-assets.mdc @@ -0,0 +1,12 @@ +--- +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/** +alwaysApply: false +--- + +# Frontend static assets + +- **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 — `` cannot inherit `currentColor`. diff --git a/.cursor/rules/lab-case-share-link.mdc b/.cursor/rules/lab-case-share-link.mdc index 918f56c..95a5247 100644 --- a/.cursor/rules/lab-case-share-link.mdc +++ b/.cursor/rules/lab-case-share-link.mdc @@ -12,7 +12,7 @@ alwaysApply: false - **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required). - **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`. - **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee. -- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login consumes **once** after org ready — ❌ do not consume in `useAuth.login()`. +- **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). - **Login page:** wrap `useSearchParams` in `` for `next build`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md` diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc index bee2fe2..ad0ba78 100644 --- a/.cursor/rules/lab-cases.mdc +++ b/.cursor/rules/lab-cases.mdc @@ -13,5 +13,6 @@ alwaysApply: false - **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. - **Detail chrome:** Important Case checkbox (`labelPosition="start"`) then external-code input (no field title; placeholder only). +- **Add case / Start / Delete draft:** `TAB_CASES_EDIT`. Delete only unstarted lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`). Skill: `.cursor/skills/lab-cases/SKILL.md` diff --git a/.cursor/rules/post-auth-navigation.mdc b/.cursor/rules/post-auth-navigation.mdc new file mode 100644 index 0000000..8980e4e --- /dev/null +++ b/.cursor/rules/post-auth-navigation.mdc @@ -0,0 +1,16 @@ +--- +description: Post-auth enter-app routing — login, register, invites, share-link redirect +globs: frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts,frontend/src/lib/hooks/useAuth.tsx,frontend/src/app/**/login/page.tsx,frontend/src/app/**/register/page.tsx,frontend/src/app/**/accept-invite/page.tsx,frontend/src/app/**/accept-organization-invite/page.tsx,frontend/src/app/**/forgot-password/page.tsx,frontend/src/app/**/(dashboard)/layout.tsx +alwaysApply: false +--- + +# Post-auth navigation + +`login()` / `registerTrial()` select the only org (or push `/select-organization`). They do **not** go to `/today`. + +- **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). +- **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`. diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index a22441a..20b55b2 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -7,12 +7,13 @@ alwaysApply: false # Treatment workspace - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. -- **Current draft** preview: heading “Current draft”; no load button while editing live. -- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. Detail-chip switches reset to Teeth unless `pendingEntryStepRef` requests Lab (shipments rail open). -- **Tooth groups:** Shift+same-arch range = connected bridge (linked dots, one shipment row); overlapping Shift ranges merge by union. Plain click = add/remove singles; click in a bridge cuts that tooth out as a selected single (end → remainder stays connected; middle → two bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send. +- **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, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). 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 once typed (sync live draft onto the open card); untyped keep 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). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **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 + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step. +- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. diff --git a/.cursor/skills/api-errors/SKILL.md b/.cursor/skills/api-errors/SKILL.md index 2e95e6d..bb9dfce 100644 --- a/.cursor/skills/api-errors/SKILL.md +++ b/.cursor/skills/api-errors/SKILL.md @@ -7,16 +7,16 @@ description: Adds or migrates Dyolink API error codes with frontend translations ## Backend -1. Add to `ErrorCode` in `backend/src/common/errors/error-codes.ts`. +1. Add code to `ErrorCode` in `backend/src/common/errors/error-codes.ts`. 2. Throw with `AppException`: ```typescript -throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST, [ - { field: 'email', code: ErrorCode.VALIDATION_EMAIL_INVALID }, -]); +throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST); ``` -3. DTOs: `@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })` +3. DTOs: always `{ message: ErrorCode.X }` on class-validator decorators (do not rely on constraint-key fallbacks — e.g. `@Matches` is not always a mobile number). +4. Do **not** throw Nest `BadRequestException('English…')` — unmapped Nest exceptions fall back to HTTP status only (`BAD_REQUEST`, `AUTH_UNAUTHORIZED`, …). +5. Wall-clock rules (working hours, weekday): pass the client **IANA** `timeZone` and use `zoned-civil-time.ts`. Never `Date#getHours()` / `getDay()` on the UTC server. ## Frontend diff --git a/.cursor/skills/i18n-formatting/SKILL.md b/.cursor/skills/i18n-formatting/SKILL.md index 59d169a..221a19e 100644 --- a/.cursor/skills/i18n-formatting/SKILL.md +++ b/.cursor/skills/i18n-formatting/SKILL.md @@ -49,8 +49,8 @@ Use when adding or changing user-visible dates/times/numbers, RTL layout, or loc | Component | Use for | |-----------|---------| -| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel | -| `CalendarDaySelect` | Navigator wrapper (arrows + panel) | +| `ScheduleDayPicker` | Appointments / Treatment day headers — nav arrows + today toggle + expandable panel. **`compact`**: no field label; date centered; Today on the navigator row | +| `CalendarDaySelect` | Navigator wrapper (arrows + panel). Empty `label` + `showHeader={false}` puts Today beside the date (used by `compact`) | | `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` | Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged. diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md index fe237c2..844dfc5 100644 --- a/.cursor/skills/lab-case-share-link/SKILL.md +++ b/.cursor/skills/lab-case-share-link/SKILL.md @@ -53,10 +53,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`). 1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`. -2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)`. -3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected). -4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`. -5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it. +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). +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. + +Rule: `.cursor/rules/post-auth-navigation.mdc`. ## Tasks tab interaction diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 202740a..397e030 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -11,12 +11,17 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## List behavior -- **Default sort:** `sentAt` desc (newest first). +- **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`. - **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`. - **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. -- **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching). +- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). 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. +- **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`. - **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination). +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. + ## Filters (`GET /cases`) | Param | Behavior | @@ -29,14 +34,14 @@ 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. +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. List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. ## Detail panel - Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) -- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. +- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. Lab-origin cases (`LAB_INTERNAL`, including after Start) are visible to lab comments/mark-read — do not require `sentAt` / `LabCaseSend`. Generated cases hide clinic-visibility controls (`clinicVisibility={false}`) — there is no clinic inbox. - Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) - FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`). - **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`. diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 22e2915..53489bb 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -26,7 +26,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. -**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). Shared `LabCaseCommentsPanel` with `viewerSide="LAB"` (newest-first, logical start/end chat align). +**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). Shared `LabCaseCommentsPanel` with `viewerSide="LAB"` (newest-first, logical start/end chat align). Generated cases (`LAB_INTERNAL`) hide clinic-visibility controls. ## Prosthesis colors @@ -39,6 +39,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. | Param | API | UI | |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | +| `origin` | `GET /tasks` | `CLINIC_DISPATCH` (received) / `LAB_INTERNAL` (generated) | | `stepCompleted` | `GET /tasks` | Workflow step dropdown | | `pinImportant` | `GET /tasks` | Important first (sort pin) | | `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 2b37d1a..4c7eb56 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -2,7 +2,7 @@ name: dyolink-treatment-workspace -description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, type-first entry, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. --- @@ -22,31 +22,32 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Appointments strip** — `AppointmentsStrip.tsx` + `ScheduleDayPicker.tsx` (Today checkbox) + `pickAutoAppointment()` in `components/shared/treatmentSelection.ts` +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add. -2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft) +2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) 3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) -4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel` +4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet only for prosthesis (`LabCasesDispatchPanel`) -## Entry wizard (Teeth / Content / Lab) +## Entry (type-first form / optional lab sheet) -Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps. +Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch tab. -| Step | UI | Notes | -|------|-----|--------| -| **Teeth** | `FdiToothChart` | Shift+click connected bridge (union if overlap); click cuts tooth from bridge as selected single | -| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** | -| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. | +| Stage | UI | When | +|-------|-----|------| +| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | +| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. | +- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. +- 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 only defaults new details). -- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content. -- Switching `activeDetailId` resets the wizard to **Teeth**, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). That ref prevents the reset effect from overwriting Lab on the first click. +- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). +- Live draft is **not** duplicated in the left rail preview; preview is for history browse only. @@ -56,8 +57,11 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group). -- **Plain click:** add/remove single; if tooth is in a **connected** bridge → cut it out as a selected **single** (second click deselects). End of bridge → remainder stays connected (≥2) or demotes to single. Middle → left and right become separate groups. Never a 1-tooth connected (no lone dots). -- **Shift+click:** inclusive same-arch range → one **connected** bridge; if the range overlaps existing bridges, **union** them into one bridge. Non-overlapping bridges stay separate. +Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`). + +- **Plain click:** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group). +- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge. +- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed. - Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown). - On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`. - Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists. @@ -104,7 +108,7 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted - **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`. - **Date** — filter on `treatmentAt` matching that local calendar day. - When **not shipped** is on, filters **already-fetched** history only (excludes the active appointment row). -- **Previous treatments rail** excludes the active appointment; live draft stays in **Current draft** preview only. Refresh history after lab send. +- **Previous treatments rail** excludes the active appointment; live draft stays in the editor only. Refresh history after lab send. Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. @@ -143,7 +147,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days: -**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. +**Today checkbox** (`ScheduleDayPicker`, including `compact` on the Treatment strip and Appointments page headers): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. @@ -179,27 +183,37 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv |----------|---------| -| `GET /appointments?from&to` | Strip | +| `GET /appointments?from&to` | Timed strip cards | + +| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards | + +| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` | + +| `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. | | `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) | | `GET /treatments/appointments/:id/draft` | Load form on appointment select | -| `PUT .../draft`, `PUT .../lab-cases` | Autosave (600ms debounce) | +| `GET/PUT /treatments/:treatmentId/draft` | Load/save when there is no appointment | + +| `PUT .../lab-cases` | Autosave (600ms debounce) — appointment or treatment id | -Draft writes require provider match (`ensureAppointmentProvider`) unless org owner. +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 → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in). + +Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. ## Edit gating ```typescript -canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' +canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && workspaceMode === 'live' ``` -Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. +`hasLiveContext` is a selected live appointment **or** standalone treatment. Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. ### Per-detail sent lock @@ -208,7 +222,7 @@ Past day or `historical` workspace mode freezes the treatment form + most lab-di ### Add / remove details - **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent. -- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`). Same gates as before: only when `details.length > 1`; disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. **Do not** put a delete control in the Content wizard step. +- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`), including the last remaining line. Disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. Empty details persist as `[]` (`SaveTreatmentDraftDto` has no `@ArrayMinSize`; `areDetailsPersistable` is `details.every(isDetailTypeSelected)` so `[]` saves). Unscheduled strip-card trash uses `areUnscheduledDetailsStripDeletable` (blank lines), then `PUT` `{ details: [] }` and `DELETE /treatments/:id` (backend still refuses when any detail row remains — `TREATMENT_HAS_DETAILS`). **Do not** put a delete control in the type/notes fields. ### Sent lab shipment fields (vs detail) diff --git a/AGENTS.md b/AGENTS.md index 6c672fd..8416eaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,42 +33,45 @@ frontend/src/ {feature}/ → feature non-UI (helpers, config, pure functions) lib/ → api clients, hooks types/ → shared TS types + assets/brand|fdi/ → brand + FDI SVG sources (not public/) — see `.cursor/rules/frontend-assets.mdc` messages/{en,fa,nl}.json → all user-facing strings ``` **Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. -**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter ``s: **`FORM_SELECT_CLASS`**; data tables: **`Table`** with logical `text-start`/`text-end` (not `text-left`/`text-right`). Skill: `.cursor/skills/i18n-formatting/SKILL.md`. **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. **Treatment edit / details (quick ref):** -- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. -- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). -- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail). -- **Tooth selection:** Shift+click same-arch range = connected bridge (one lab row); overlapping Shift bridges merge by union. Plain click add/remove singles; click in a bridge cuts that tooth out as a selected single (ends keep one bridge; middle splits into two). Never a 1-tooth connected (no lone dots). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. +- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). 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, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). 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; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. - **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile). +- **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):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the entry wizard **Lab** step. +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **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. See `.cursor/rules/appointments.mdc`. +**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`. -**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`. +**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`. -**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. **Lab case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. - **Lab:** view/edit tasks (assignee rules), comments + visibility toggle. - **Clinic:** treatment **provider** only — read-only tasks, can comment. -- Logged out → login with `?from=` → single `consumeAuthRedirect()` after org ready (not inside `useAuth.login()`). +- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`. **Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`. @@ -93,7 +96,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | -| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, type-first entry, connected teeth | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, Case Sheet PDF, external code | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | diff --git a/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql b/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql new file mode 100644 index 0000000..df409a4 --- /dev/null +++ b/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql @@ -0,0 +1,79 @@ +-- AlterTable +ALTER TABLE "patients" ADD COLUMN "isWalkIn" BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX "patients_createdByOrganizationId_isWalkIn_idx" ON "patients"("createdByOrganizationId", "isWalkIn"); + +CREATE UNIQUE INDEX "patients_one_walkin_per_org" ON "patients"("createdByOrganizationId") WHERE "isWalkIn" = true; + +-- CreateEnum +CREATE TYPE "LabCaseOrigin" AS ENUM ('CLINIC_DISPATCH', 'LAB_INTERNAL'); + +-- AlterTable +ALTER TABLE "lab_cases" ALTER COLUMN "treatmentId" DROP NOT NULL; +ALTER TABLE "lab_cases" ALTER COLUMN "sortOrder" SET DEFAULT 0; +ALTER TABLE "lab_cases" ADD COLUMN "startedAt" TIMESTAMP(3); +ALTER TABLE "lab_cases" ADD COLUMN "origin" "LabCaseOrigin" NOT NULL DEFAULT 'CLINIC_DISPATCH'; +ALTER TABLE "lab_cases" ADD COLUMN "referringClinicName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "referringDentistName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "patientDisplayName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "patientDisplayMobile" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "partnerClinicOrganizationId" TEXT; + +CREATE INDEX "lab_cases_destinationOrganizationId_origin_idx" ON "lab_cases"("destinationOrganizationId", "origin"); +CREATE INDEX "lab_cases_partnerClinicOrganizationId_idx" ON "lab_cases"("partnerClinicOrganizationId"); + +ALTER TABLE "lab_cases" ADD CONSTRAINT "lab_cases_partnerClinicOrganizationId_fkey" FOREIGN KEY ("partnerClinicOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "lab_case_lines" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL DEFAULT 'prosthesis', + "teeth" JSONB NOT NULL, + "toothSelectionGroups" JSONB, + "comment" TEXT, + + CONSTRAINT "lab_case_lines_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "lab_case_lines_labCaseId_sortOrder_idx" ON "lab_case_lines"("labCaseId", "sortOrder"); + +ALTER TABLE "lab_case_lines" ADD CONSTRAINT "lab_case_lines_labCaseId_fkey" FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Tooth prosthesis: nullable detail, lineId, sourceKey, treatmentType +DROP INDEX IF EXISTS "lab_case_tooth_prosthesis_labCaseId_treatmentDetailId_tooth_key"; +ALTER TABLE "lab_case_tooth_prosthesis" ALTER COLUMN "treatmentDetailId" DROP NOT NULL; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "lineId" TEXT; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "sourceKey" TEXT; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "treatmentType" TEXT NOT NULL DEFAULT 'prosthesis'; + +UPDATE "lab_case_tooth_prosthesis" SET "sourceKey" = "treatmentDetailId" WHERE "sourceKey" IS NULL AND "treatmentDetailId" IS NOT NULL; + +ALTER TABLE "lab_case_tooth_prosthesis" ALTER COLUMN "sourceKey" SET NOT NULL; + +CREATE UNIQUE INDEX "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key" ON "lab_case_tooth_prosthesis"("labCaseId", "sourceKey", "tooth"); + +ALTER TABLE "lab_case_tooth_prosthesis" ADD CONSTRAINT "lab_case_tooth_prosthesis_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "lab_case_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Tasks: nullable detail, lineId, sourceKey +ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"; +DROP INDEX IF EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"; + +ALTER TABLE "lab_case_tasks" ALTER COLUMN "treatmentDetailId" DROP NOT NULL; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lineId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "sourceKey" TEXT; + +UPDATE "lab_case_tasks" SET "sourceKey" = "treatmentDetailId" WHERE "sourceKey" IS NULL AND "treatmentDetailId" IS NOT NULL; + +ALTER TABLE "lab_case_tasks" ALTER COLUMN "sourceKey" SET NOT NULL; + +CREATE UNIQUE INDEX "lab_case_tasks_case_source_prosthesis_step_key" ON "lab_case_tasks"("labCaseId", "sourceKey", "prosthesisTypeCode", "stepOrder"); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "lab_case_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "treatments_organizationId_providerUserId_treatmentAt_idx" ON "treatments"("organizationId", "providerUserId", "treatmentAt"); + +ALTER TABLE "treatment_detail_attachments" ADD COLUMN "treatmentId" TEXT; +CREATE INDEX "treatment_detail_attachments_treatmentId_detailClientKey_idx" ON "treatment_detail_attachments"("treatmentId", "detailClientKey"); diff --git a/backend/prisma/regenerate-lab-tasks.ts b/backend/prisma/regenerate-lab-tasks.ts index 31cf1ae..cd4229c 100644 --- a/backend/prisma/regenerate-lab-tasks.ts +++ b/backend/prisma/regenerate-lab-tasks.ts @@ -37,6 +37,7 @@ async function main() { let total = 0; for (const labCase of cases) { + if (!labCase.treatment) continue; const locale = labCase.treatment.organization.owner.language ?? 'en'; // Clear any stale tasks first so the generator's "already exists" guard passes. await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } }); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 66d1992..b1508d2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -90,6 +90,7 @@ model Organization { treatments Treatment[] labCaseSends LabCaseSend[] labCaseComments LabCaseComment[] + partnerLabCases LabCase[] @relation("LabCasePartnerClinic") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -106,6 +107,7 @@ model Patient { dateOfBirth DateTime? notes String? isActive Boolean @default(true) + isWalkIn Boolean @default(false) createdByOrganizationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -115,6 +117,7 @@ model Patient { appointments Appointment[] @@index([lastName, firstName]) + @@index([createdByOrganizationId, isWalkIn]) @@map("patients") } @@ -149,6 +152,11 @@ enum LabCaseCommentSide { CLINIC } +enum LabCaseOrigin { + CLINIC_DISPATCH + LAB_INTERNAL +} + model Treatment { id String @id @default(uuid()) organizationId String @@ -168,6 +176,7 @@ model Treatment { updatedAt DateTime @updatedAt @@index([patientId, treatmentAt]) + @@index([organizationId, providerUserId, treatmentAt]) @@map("treatments") } @@ -196,6 +205,7 @@ model TreatmentDetailAttachment { id String @id @default(uuid()) detailId String? appointmentId String? + treatmentId String? detailClientKey String? fileName String mimeType String @@ -208,25 +218,35 @@ model TreatmentDetailAttachment { createdAt DateTime @default(now()) @@index([appointmentId, detailClientKey]) + @@index([treatmentId, detailClientKey]) @@index([detailId]) @@map("treatment_detail_attachments") } model LabCase { - id String @id @default(uuid()) - treatmentId String + id String @id @default(uuid()) + treatmentId String? clientKey String? - sortOrder Int + sortOrder Int @default(0) destinationOrganizationId String? sentAt DateTime? + startedAt DateTime? dueDate DateTime? - isImportant Boolean @default(false) + isImportant Boolean @default(false) + origin LabCaseOrigin @default(CLINIC_DISPATCH) /// Optional code from an external lab app (e.g. exocad) for print/matching. externalCode String? - accessToken String? @unique + accessToken String? @unique + referringClinicName String? + referringDentistName String? + patientDisplayName String? + patientDisplayMobile String? + partnerClinicOrganizationId String? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + partnerClinic Organization? @relation("LabCasePartnerClinic", fields: [partnerClinicOrganizationId], references: [id], onDelete: SetNull) details LabCaseDetail[] + lines LabCaseLine[] sends LabCaseSend[] tasks LabCaseTask[] toothProsthesis LabCaseToothProsthesis[] @@ -236,9 +256,30 @@ model LabCase { userReadStates LabCaseUserReadState[] @@index([treatmentId, sortOrder]) + @@index([destinationOrganizationId, origin]) + @@index([partnerClinicOrganizationId]) @@map("lab_cases") } +/// Lab-origin work lines (analog of TreatmentDetail). Clinic-sent cases do not use this table. +model LabCaseLine { + id String @id @default(uuid()) + labCaseId String + clientKey String? + sortOrder Int + treatmentType String @default("prosthesis") + teeth Json + toothSelectionGroups Json? + comment String? + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + toothProsthesis LabCaseToothProsthesis[] + tasks LabCaseTask[] + + @@index([labCaseId, sortOrder]) + @@map("lab_case_lines") +} + model LabCaseAttachment { labCaseId String attachmentId String @@ -343,23 +384,31 @@ model ProsthesisTypeStep { model LabCaseToothProsthesis { id String @id @default(uuid()) labCaseId String - treatmentDetailId String + treatmentDetailId String? + lineId String? + /// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key. + sourceKey String tooth String prosthesisTypeCode String - /** Links to TreatmentDetail.toothSelectionGroups[].groupId for task grouping. */ - selectionGroupId String @default("") + treatmentType String @default("prosthesis") + /** Links to TreatmentDetail/LabCaseLine toothSelectionGroups[].groupId for task grouping. */ + selectionGroupId String @default("") - labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) - detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) - @@unique([labCaseId, treatmentDetailId, tooth]) + @@unique([labCaseId, sourceKey, tooth]) @@map("lab_case_tooth_prosthesis") } model LabCaseTask { id String @id @default(uuid()) labCaseId String - treatmentDetailId String + treatmentDetailId String? + lineId String? + /// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key. + sourceKey String teeth Json treatmentType String prosthesisTypeCode String @@ -374,16 +423,17 @@ model LabCaseTask { lastStatusChangedByUserId String? lastStatusChangedAt DateTime? - labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) - detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) - assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) statusEvents LabCaseTaskStatusEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key") + @@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") diff --git a/backend/prisma/wipe-app-data.ts b/backend/prisma/wipe-app-data.ts index e3680b5..8cc2166 100644 --- a/backend/prisma/wipe-app-data.ts +++ b/backend/prisma/wipe-app-data.ts @@ -41,6 +41,9 @@ const TABLES_IN_ORDER = [ 'staff_invitations', 'membership_permissions', 'sessions', + 'user_notifications', + 'lab_case_user_read_states', + 'lab_case_user_tab_read_states', 'lab_case_task_status_events', 'lab_case_comments', 'lab_case_attachments', @@ -49,6 +52,7 @@ const TABLES_IN_ORDER = [ 'lab_case_tooth_prosthesis', 'lab_case_details', 'lab_cases', + 'lab_case_activities', 'treatment_detail_attachments', 'treatment_details', 'treatments', diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 231de83..5c17305 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -57,14 +57,130 @@ export const ErrorCode = { VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED', VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID', VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST', + VALIDATION_TIMEZONE_INVALID: 'VALIDATION_TIMEZONE_INVALID', + + // Appointments + APPOINTMENT_INVALID_TIME: 'APPOINTMENT_INVALID_TIME', + APPOINTMENT_END_BEFORE_START: 'APPOINTMENT_END_BEFORE_START', + APPOINTMENT_TOO_LONG: 'APPOINTMENT_TOO_LONG', + APPOINTMENT_IN_PAST: 'APPOINTMENT_IN_PAST', + APPOINTMENT_INVALID_RANGE: 'APPOINTMENT_INVALID_RANGE', + APPOINTMENT_INVALID_RANGE_ORDER: 'APPOINTMENT_INVALID_RANGE_ORDER', + APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE', + APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER', + APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE', + APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT', + APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS', + APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY', + APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS', + APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND', + APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', + APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', + APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER', + + PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND', + + WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID', + WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED', + WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS', + + STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS', + STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION', + STAFF_SEAT_LIMIT: 'STAFF_SEAT_LIMIT', + STAFF_INVITE_OWNER_EMAIL: 'STAFF_INVITE_OWNER_EMAIL', + STAFF_ALREADY_MEMBER: 'STAFF_ALREADY_MEMBER', + STAFF_OWNER_NO_INVITE_LINK: 'STAFF_OWNER_NO_INVITE_LINK', + STAFF_ALREADY_ACCEPTED: 'STAFF_ALREADY_ACCEPTED', + STAFF_INVITE_MISSING: 'STAFF_INVITE_MISSING', + STAFF_INVITE_ALREADY_ACCEPTED: 'STAFF_INVITE_ALREADY_ACCEPTED', + STAFF_INVITE_INVALID: 'STAFF_INVITE_INVALID', + STAFF_INVITE_REVOKED: 'STAFF_INVITE_REVOKED', + STAFF_INVITE_EXPIRED: 'STAFF_INVITE_EXPIRED', + STAFF_ALREADY_ACTIVE: 'STAFF_ALREADY_ACTIVE', + STAFF_ALREADY_DISABLED: 'STAFF_ALREADY_DISABLED', + STAFF_CANNOT_DISABLE_SELF: 'STAFF_CANNOT_DISABLE_SELF', + STAFF_MEMBER_NOT_FOUND: 'STAFF_MEMBER_NOT_FOUND', + STAFF_CANNOT_EDIT_OWNER: 'STAFF_CANNOT_EDIT_OWNER', + STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER', + STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER', + STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER', + + ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF', + ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE', + ORG_TARGET_NO_SUBSCRIPTION: 'ORG_TARGET_NO_SUBSCRIPTION', + ORG_LINK_EXISTS: 'ORG_LINK_EXISTS', + ORG_REQUEST_NOT_PENDING: 'ORG_REQUEST_NOT_PENDING', + ORG_CANNOT_RESPOND_OWN: 'ORG_CANNOT_RESPOND_OWN', + ORG_INVITE_ALREADY_ACCEPTED: 'ORG_INVITE_ALREADY_ACCEPTED', + ORG_INVITE_INVALID: 'ORG_INVITE_INVALID', + ORG_INVITE_REVOKED: 'ORG_INVITE_REVOKED', + ORG_INVITE_EXPIRED: 'ORG_INVITE_EXPIRED', + ORG_INVITE_TYPE_MISMATCH: 'ORG_INVITE_TYPE_MISMATCH', + ORG_INVITE_OWNER_HAS_ORG: 'ORG_INVITE_OWNER_HAS_ORG', + ORG_UNKNOWN_TYPE: 'ORG_UNKNOWN_TYPE', + ORG_COUNTERPART_NOT_LAB: 'ORG_COUNTERPART_NOT_LAB', + ORG_COUNTERPART_NOT_CLINIC: 'ORG_COUNTERPART_NOT_CLINIC', + ORG_CONNECTION_NOT_FOUND: 'ORG_CONNECTION_NOT_FOUND', + ORG_INVITE_NOT_FOUND: 'ORG_INVITE_NOT_FOUND', + ORG_ONLY_CLINIC_COMMENT: 'ORG_ONLY_CLINIC_COMMENT', + + CATALOG_UNKNOWN_TREATMENT_TYPE: 'CATALOG_UNKNOWN_TREATMENT_TYPE', + CATALOG_UNKNOWN_PROSTHESIS_TYPE: 'CATALOG_UNKNOWN_PROSTHESIS_TYPE', + CATALOG_TREATMENT_NOT_LAB_DEPENDENT: 'CATALOG_TREATMENT_NOT_LAB_DEPENDENT', + + TREATMENT_SAVE_DETAILS_BEFORE_LAB: 'TREATMENT_SAVE_DETAILS_BEFORE_LAB', + TREATMENT_DETAIL_ONE_CASE: 'TREATMENT_DETAIL_ONE_CASE', + TREATMENT_DETAILS_NOT_FOUND: 'TREATMENT_DETAILS_NOT_FOUND', + TREATMENT_DEST_NOT_LINKED: 'TREATMENT_DEST_NOT_LINKED', + TREATMENT_TEETH_REQUIRED_TO_SHIP: 'TREATMENT_TEETH_REQUIRED_TO_SHIP', + TREATMENT_TOOTH_UNKNOWN_DETAIL: 'TREATMENT_TOOTH_UNKNOWN_DETAIL', + TREATMENT_TOOTH_NOT_ON_DETAIL: 'TREATMENT_TOOTH_NOT_ON_DETAIL', + TREATMENT_CASE_NO_DEST: 'TREATMENT_CASE_NO_DEST', + TREATMENT_CASE_NEEDS_DETAIL: 'TREATMENT_CASE_NEEDS_DETAIL', + TREATMENT_CASE_ONE_DETAIL: 'TREATMENT_CASE_ONE_DETAIL', + TREATMENT_ONLY_PROVIDER_SEND: 'TREATMENT_ONLY_PROVIDER_SEND', + TREATMENT_CASE_ALREADY_SENT: 'TREATMENT_CASE_ALREADY_SENT', + TREATMENT_DUE_DATE_LOCKED: 'TREATMENT_DUE_DATE_LOCKED', + TREATMENT_INVALID_DUE_DATE: 'TREATMENT_INVALID_DUE_DATE', + TREATMENT_DETAIL_KEY_REQUIRED: 'TREATMENT_DETAIL_KEY_REQUIRED', + TREATMENT_FILE_REQUIRED: 'TREATMENT_FILE_REQUIRED', + TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND', + TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE', + TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS', + TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE', + TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT', + TREATMENT_NOT_FOUND: 'TREATMENT_NOT_FOUND', + TREATMENT_PATIENT_OR_WALK_IN: 'TREATMENT_PATIENT_OR_WALK_IN', + TREATMENT_NOT_STANDALONE: 'TREATMENT_NOT_STANDALONE', + TREATMENT_HAS_DETAILS: 'TREATMENT_HAS_DETAILS', + LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND', + LAB_CASE_NOT_EDITABLE: 'LAB_CASE_NOT_EDITABLE', + LAB_CASE_ALREADY_STARTED: 'LAB_CASE_ALREADY_STARTED', + LAB_CASE_LINES_REQUIRED: 'LAB_CASE_LINES_REQUIRED', + LAB_CASE_START_INCOMPLETE: 'LAB_CASE_START_INCOMPLETE', + LAB_CASE_CLIENT_REQUIRED: 'LAB_CASE_CLIENT_REQUIRED', + + TASK_ASSIGNEE_INVALID: 'TASK_ASSIGNEE_INVALID', + TASK_ASSIGNED_TO_OTHER: 'TASK_ASSIGNED_TO_OTHER', + TASK_PAGE_DATE_SORT_ONLY: 'TASK_PAGE_DATE_SORT_ONLY', + INVALID_SENT_FROM: 'INVALID_SENT_FROM', + INVALID_SENT_TO: 'INVALID_SENT_TO', + + CASE_NOT_FOUND: 'CASE_NOT_FOUND', + CASE_ATTACHMENT_NOT_FOUND: 'CASE_ATTACHMENT_NOT_FOUND', + CASE_ATTACHMENT_MISSING_FILE: 'CASE_ATTACHMENT_MISSING_FILE', + CASE_TASK_NOT_FOUND: 'CASE_TASK_NOT_FOUND', + + COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND', + COMMENT_VISIBILITY_LAB_ONLY: 'COMMENT_VISIBILITY_LAB_ONLY', + + TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE', + TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER', // Generic HTTP NOT_FOUND: 'NOT_FOUND', CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', - APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', - APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', - TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/common/errors/http-exception.filter.ts b/backend/src/common/errors/http-exception.filter.ts index cc9d3d7..a522e73 100644 --- a/backend/src/common/errors/http-exception.filter.ts +++ b/backend/src/common/errors/http-exception.filter.ts @@ -28,58 +28,6 @@ const STATUS_FALLBACK_CODES: Partial> = { [HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR, }; -/** Maps legacy English messages to stable codes during migration. */ -const LEGACY_MESSAGE_CODES: Record = { - 'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS, - Unauthorized: ErrorCode.AUTH_UNAUTHORIZED, - 'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND, - 'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, - 'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, - 'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, - 'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED, - 'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED, - 'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED, - 'User not found': ErrorCode.AUTH_USER_NOT_FOUND, - 'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED, - 'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED, - 'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING, - 'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED, - 'User already exists. Please login and create a new organization from your account.': - ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS, - 'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS, - 'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, - 'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT, - 'Password reset verification expired. Please verify your mobile again.': - ErrorCode.AUTH_PASSWORD_RESET_EXPIRED, - 'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID, - 'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT, - 'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED, - 'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST, - 'Only owners of the current organization can create new organizations.': - ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY, - 'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY, - 'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY, - 'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND, - 'Unknown organization type': ErrorCode.PERMISSION_DENIED, - 'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE, - 'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER, - 'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY, - 'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY, - 'An active subscription is required to participate in treatments or tasks': - ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION, - 'Enable treatment participation before setting working hours': - ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, - 'Working hours are only available for clinic organizations': - ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, - 'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, - 'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS, - 'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS, - 'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS, - 'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS, - 'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF, - 'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF, -}; - @Catch() export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name); @@ -121,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter { }; } - const code = this.resolveLegacyCode(rawResponse, statusCode); + const code = this.resolveFallbackCode(rawResponse, statusCode); return { statusCode, body: this.buildBody(code, statusCode), @@ -137,20 +85,12 @@ export class HttpExceptionFilter implements ExceptionFilter { ); } - private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue { + /** Nest/Passport leftovers: validation arrays, otherwise HTTP status. */ + private resolveFallbackCode(rawResponse: string | object, statusCode: number): ErrorCodeValue { const message = this.extractMessage(rawResponse); - - if (typeof message === 'string') { - const mapped = LEGACY_MESSAGE_CODES[message.trim()]; - if (mapped) { - return mapped; - } - } - if (Array.isArray(message)) { return ErrorCode.VALIDATION_FAILED; } - return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR; } diff --git a/backend/src/common/errors/validation-exception.factory.ts b/backend/src/common/errors/validation-exception.factory.ts index 4d85008..3d032db 100644 --- a/backend/src/common/errors/validation-exception.factory.ts +++ b/backend/src/common/errors/validation-exception.factory.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { HttpException, HttpStatus } from '@nestjs/common'; import type { ValidationError } from 'class-validator'; import { AppException } from './app.exception'; import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes'; @@ -22,7 +22,12 @@ function constraintToCode(constraintKey: string, message: string): ErrorCodeValu case 'isEnum': return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID; case 'matches': - return ErrorCode.VALIDATION_MOBILE_INVALID; + case 'isDateString': + case 'isUuid': + case 'isInt': + case 'isBoolean': + case 'isArray': + return ErrorCode.VALIDATION_INVALID_REQUEST; case 'isIn': return ErrorCode.VALIDATION_LANGUAGE_INVALID; case 'whitelistValidation': @@ -66,8 +71,3 @@ export function validationExceptionFactory(errors: ValidationError[]): HttpExcep return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details); } - -/** Handles forbidNonWhitelisted errors from ValidationPipe. */ -export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException { - return exception instanceof BadRequestException; -} diff --git a/backend/src/common/walk-in-patient.ts b/backend/src/common/walk-in-patient.ts new file mode 100644 index 0000000..cadcdf3 --- /dev/null +++ b/backend/src/common/walk-in-patient.ts @@ -0,0 +1,40 @@ +import type { Prisma } from '@prisma/client'; +import type { PrismaService } from '../../prisma/prisma.service'; + +export const WALK_IN_MOBILE_PREFIX = '__walkin__:'; + +export function walkInMobileForOrganization(organizationId: string): string { + return `${WALK_IN_MOBILE_PREFIX}${organizationId}`; +} + +export function isWalkInMobile(mobile: string): boolean { + return mobile.startsWith(WALK_IN_MOBILE_PREFIX); +} + +type PrismaClientLike = PrismaService | Prisma.TransactionClient; + +/** One sentinel Patient per clinic for treatments without a named patient. */ +export async function ensureWalkInPatient( + prisma: PrismaClientLike, + organizationId: string, +): Promise<{ id: string; isWalkIn: true }> { + const existing = await prisma.patient.findFirst({ + where: { createdByOrganizationId: organizationId, isWalkIn: true }, + select: { id: true }, + }); + if (existing) { + return { id: existing.id, isWalkIn: true }; + } + + const created = await prisma.patient.create({ + data: { + firstName: 'Walk-in', + lastName: 'Walk-in', + mobile: walkInMobileForOrganization(organizationId), + isWalkIn: true, + createdByOrganizationId: organizationId, + }, + select: { id: true }, + }); + return { id: created.id, isWalkIn: true }; +} diff --git a/backend/src/common/working-hours.ts b/backend/src/common/working-hours.ts index 0cbdf60..1c5b5af 100644 --- a/backend/src/common/working-hours.ts +++ b/backend/src/common/working-hours.ts @@ -1,3 +1,5 @@ +import { zonedWeekdayAndMinutes } from './zoned-civil-time'; + export const MINUTES_PER_DAY = 24 * 60; export type WorkingHoursBlockInput = { @@ -82,17 +84,18 @@ export function isRangeWithinWorkingBlocks( return true; } -export function dateToLocalMinutes(date: Date): number { - return date.getHours() * 60 + date.getMinutes(); +export function dateToZonedMinutes(date: Date, timeZone: string): number { + return zonedWeekdayAndMinutes(date, timeZone).minuteOfDay; } export function appointmentWithinWorkingHours( startAt: Date, endAt: Date, dayBlocks: WorkingHoursDayBlock[], + timeZone: string, ): boolean { - const startMinute = dateToLocalMinutes(startAt); - const endMinute = dateToLocalMinutes(endAt); + const startMinute = dateToZonedMinutes(startAt, timeZone); + const endMinute = dateToZonedMinutes(endAt, timeZone); return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks); } diff --git a/backend/src/common/zoned-civil-time.spec.ts b/backend/src/common/zoned-civil-time.spec.ts new file mode 100644 index 0000000..99279ab --- /dev/null +++ b/backend/src/common/zoned-civil-time.spec.ts @@ -0,0 +1,29 @@ +import { appointmentWithinWorkingHours } from './working-hours'; +import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time'; + +describe('zoned civil time', () => { + it('accepts IANA zones and rejects garbage', () => { + expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true); + expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true); + expect(isValidIanaTimeZone('Not/AZone')).toBe(false); + }); + + it('maps a UTC instant to Tehran wall-clock (no DST)', () => { + // 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case) + const instant = new Date('2026-08-20T06:15:00.000Z'); + const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran'); + expect(zoned.jsWeekday).toBe(4); // Thursday + expect(zoned.minuteOfDay).toBe(9 * 60 + 45); + }); + + it('treats morning Tehran slots as inside 08:00–17:00 hours', () => { + const start = new Date('2026-08-20T06:15:00.000Z'); + const end = new Date('2026-08-20T06:45:00.000Z'); + expect( + appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'), + ).toBe(true); + expect( + appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'), + ).toBe(false); + }); +}); diff --git a/backend/src/common/zoned-civil-time.ts b/backend/src/common/zoned-civil-time.ts new file mode 100644 index 0000000..88679ef --- /dev/null +++ b/backend/src/common/zoned-civil-time.ts @@ -0,0 +1,55 @@ +/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */ + +const JS_WEEKDAY: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +export function isValidIanaTimeZone(timeZone: string): boolean { + if (!timeZone || timeZone.length > 64) { + return false; + } + try { + Intl.DateTimeFormat('en-US', { timeZone }).format(new Date(0)); + return true; + } catch { + return false; + } +} + +export function zonedWeekdayAndMinutes( + date: Date, + timeZone: string, +): { jsWeekday: number; minuteOfDay: number } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + + const weekdayToken = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun'; + let hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0'); + const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0'); + if (hour === 24) { + hour = 0; + } + + return { + jsWeekday: JS_WEEKDAY[weekdayToken] ?? 0, + minuteOfDay: hour * 60 + minute, + }; +} + +/** Weekday of a YYYY-MM-DD civil date (Gregorian; same worldwide). */ +export function civilDateJsWeekday(isoDate: string): number { + const [y, m, d] = isoDate.split('-').map(Number); + const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0)); + return utcNoon.getUTCDay(); +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 0a4755f..2b760df 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -1,9 +1,6 @@ import { - BadRequestException, - ForbiddenException, HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { @@ -11,6 +8,7 @@ import { blocksForDay, localDayOfWeekMondayZero, } from '../../common/working-hours'; +import { civilDateJsWeekday, isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time'; import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; @@ -31,7 +29,7 @@ export class AppointmentsService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -88,11 +86,11 @@ export class AppointmentsService { const to = new Date(query.to); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { - throw new BadRequestException('Invalid date range'); + throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST); } if (to <= from) { - throw new BadRequestException('Range "to" must be after "from"'); + throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST); } const items = await this.prisma.appointment.findMany({ @@ -129,22 +127,23 @@ export class AppointmentsService { const startAt = new Date(dto.startAt); const endAt = new Date(dto.endAt); + const timeZone = this.requireTimeZone(dto.timeZone); if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) { - throw new BadRequestException('Invalid start or end time'); + throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST); } if (endAt <= startAt) { - throw new BadRequestException('End time must be after start time'); + throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST); } if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) { - throw new BadRequestException('Appointment cannot span more than 24 hours'); + throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST); } const now = Date.now(); if (startAt.getTime() < now) { - throw new BadRequestException('Cannot schedule appointments in the past'); + throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST); } await this.ensurePatientInOrg(dto.patientId, organizationId); @@ -155,6 +154,7 @@ export class AppointmentsService { organizationId, startAt, endAt, + timeZone, ); const appointment = await this.prisma.appointment.create({ @@ -189,22 +189,23 @@ export class AppointmentsService { }); if (!existing) { - throw new NotFoundException('Appointment not found'); + throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt; const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt; + const timeZone = this.requireTimeZone(dto.timeZone); if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) { - throw new BadRequestException('Invalid start or end time'); + throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST); } if (endAt <= startAt) { - throw new BadRequestException('End time must be after start time'); + throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST); } if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) { - throw new BadRequestException('Appointment cannot span more than 24 hours'); + throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST); } const patientId = dto.patientId ?? existing.patientId; @@ -230,6 +231,7 @@ export class AppointmentsService { organizationId, startAt, endAt, + timeZone, ); const appointment = await this.prisma.appointment.update({ @@ -260,7 +262,7 @@ export class AppointmentsService { }); if (!existing) { - throw new NotFoundException('Appointment not found'); + throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (existing.treatment) { @@ -277,7 +279,7 @@ export class AppointmentsService { private async assertCanViewAppointments(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) { return; @@ -286,7 +288,7 @@ export class AppointmentsService { if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) { return; } - throw new ForbiddenException('You do not have access to appointments'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN); } private async assertCanListAppointmentsForTreatment( @@ -295,7 +297,7 @@ export class AppointmentsService { ) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) { return { membership: m, scopeToProvider: false as const }; @@ -306,7 +308,7 @@ export class AppointmentsService { const canViewTreatment = names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT'); if (!canViewSchedule && !canViewTreatment) { - throw new ForbiddenException('You do not have access to appointments'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN); } return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment }; } @@ -314,7 +316,7 @@ export class AppointmentsService { private async assertCanEditAppointments(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) { return; @@ -323,42 +325,47 @@ export class AppointmentsService { if (names.includes('TAB_APPOINTMENTS_EDIT')) { return; } - throw new ForbiddenException('You cannot create or modify appointments'); + throw new AppException(ErrorCode.PERMISSION_EDIT_APPOINTMENTS, HttpStatus.FORBIDDEN); } private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) { const m = await this.getMembership(providerUserId, organizationId); if (!m) { - throw new BadRequestException('Provider is not a member of this organization'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST); } if (!m.isOwner && !m.isActive) { - throw new BadRequestException('Provider is not an active staff member'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST); } if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) { - throw new BadRequestException('Provider does not have treatment edit access'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST); } } private async ensurePatientInOrg(patientId: string, _organizationId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, - select: { id: true }, + select: { id: true, isWalkIn: true }, }); - if (!patient) { - throw new NotFoundException('Patient not found'); + if (!patient || patient.isWalkIn) { + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } + private requireTimeZone(timeZone: string | undefined): string { + if (!timeZone || !isValidIanaTimeZone(timeZone)) { + throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST); + } + return timeZone; + } + private resolveDayOfWeekMondayZero(date?: string): number { if (!date) { - return localDayOfWeekMondayZero(new Date().getDay()); + return localDayOfWeekMondayZero(new Date().getUTCDay()); } - const [y, m, d] = date.split('-').map(Number); - const parsed = new Date(y, m - 1, d, 12, 0, 0, 0); - if (Number.isNaN(parsed.getTime())) { - throw new BadRequestException('Invalid date query parameter'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST); } - return localDayOfWeekMondayZero(parsed.getDay()); + return localDayOfWeekMondayZero(civilDateJsWeekday(date)); } private async ensureAppointmentWithinProviderWorkingHours( @@ -366,27 +373,28 @@ export class AppointmentsService { organizationId: string, startAt: Date, endAt: Date, + timeZone: string, ) { const membership = await this.getMembership(providerUserId, organizationId); if (!membership) { - throw new BadRequestException('Provider is not a member of this organization'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST); } const scheduleBlocksByMembership = await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]); const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? []; if (allBlocks.length === 0) { - throw new BadRequestException('Provider has no working hours configured'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST); } - const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); + const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday); const dayBlocks = blocksForDay(allBlocks, dayOfWeek); if (dayBlocks.length === 0) { - throw new BadRequestException('Provider is not working on this day'); + throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST); } - if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) { - throw new BadRequestException('Appointment must fall within the provider working hours'); + if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) { + throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST); } } diff --git a/backend/src/modules/appointments/dto/column-providers-query.dto.ts b/backend/src/modules/appointments/dto/column-providers-query.dto.ts index 04ccc58..0e1607a 100644 --- a/backend/src/modules/appointments/dto/column-providers-query.dto.ts +++ b/backend/src/modules/appointments/dto/column-providers-query.dto.ts @@ -1,9 +1,10 @@ import { IsOptional, IsString, Matches } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class ColumnProvidersQueryDto { /** Local calendar date (YYYY-MM-DD) used to resolve weekday working hours. */ @IsOptional() @IsString() - @Matches(/^\d{4}-\d{2}-\d{2}$/) + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: ErrorCode.APPOINTMENT_INVALID_DATE }) date?: string; } diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts index fb34434..3ca10af 100644 --- a/backend/src/modules/appointments/dto/create-appointment.dto.ts +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator'; +import { IsDateString, IsString, IsUUID, Matches, MaxLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class CreateAppointmentDto { @ApiProperty() @@ -25,4 +26,14 @@ export class CreateAppointmentDto { @IsString() @MaxLength(64) purpose: string; + + @ApiProperty({ + description: + 'IANA time zone of the booking user (e.g. Asia/Tehran). Wall-clock hours are interpreted in this zone.', + example: 'Asia/Tehran', + }) + @IsString() + @MaxLength(64) + @Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID }) + timeZone: string; } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 3bcc7b8..00c02e0 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -1,20 +1,34 @@ import { Body, Controller, + Delete, Get, Param, Patch, + Post, + Put, Query, Req, Res, + UploadedFiles, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; import type { Response } from 'express'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { + ListLabCasesDto, + UpdateLabCaseImportantDto, + UpdateLabCaseExternalCodeDto, + AssignLabCaseTaskDto, + CreateLabInternalCaseDto, + UpdateLabInternalCaseDto, +} from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -30,6 +44,13 @@ export class CasesController { return this.casesService.list(organizationId, req.user.id, query); } + @Post() + @ApiOperation({ summary: 'Create a lab-origin draft case (TAB_CASES_EDIT)' }) + create(@Body() dto: CreateLabInternalCaseDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.createInternal(dto, organizationId, req.user.id, req.user.language); + } + @Get('assignable-staff') @ApiOperation({ summary: 'Staff who can be assigned lab tasks' }) listAssignableStaff(@Req() req) { @@ -44,6 +65,13 @@ export class CasesController { return this.casesService.listFilterOptions(organizationId, req.user.id); } + @Get('linked-clinics') + @ApiOperation({ summary: 'Active linked clinic organizations for lab-origin cases' }) + listLinkedClinics(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listLinkedClinics(organizationId, req.user.id); + } + @Get(':id') @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) getOne(@Param('id') id: string, @Req() req) { @@ -51,6 +79,51 @@ export class CasesController { return this.casesService.getOne(id, organizationId, req.user.id, req.user.language); } + @Put(':id') + @ApiOperation({ summary: 'Update a lab-origin draft case and its lines' }) + update(@Param('id') id: string, @Body() dto: UpdateLabInternalCaseDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.updateInternal(id, dto, organizationId, req.user.id, req.user.language); + } + + @Post(':id/start') + @ApiOperation({ summary: 'Start a lab-origin draft (generate tasks, no clinic notify)' }) + start(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an unstarted lab-origin draft (TAB_CASES_EDIT)' }) + deleteDraft(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.deleteInternalDraft(id, organizationId, req.user.id); + } + + @Post(':id/lines/:lineClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadAttachments( + @Param('id') id: string, + @Param('lineClientKey') lineClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.uploadInternalAttachments( + id, + lineClientKey, + files, + organizationId, + req.user.id, + ); + } + @Get(':id/attachments/:attachmentId/file') @ApiOperation({ summary: 'Download an attachment shared with this lab case' }) async downloadAttachment( diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 94976fc..93028e7 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -1,11 +1,12 @@ import { - BadRequestException, - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; -import { createReadStream, existsSync } from 'fs'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; +import { AppException, ErrorCode } from '../../common/errors'; +import { randomUUID } from 'crypto'; +import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -14,7 +15,8 @@ import { } from '../catalog/catalog-label.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto'; +import { generateLabCaseTasks } from './lab-case-task.generator'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; @@ -33,6 +35,8 @@ const labCaseListInclude = { appointment: { select: { startAt: true } }, }, }, + partnerClinic: { select: { id: true, name: true } }, + lines: { orderBy: [{ sortOrder: 'asc' as const }] }, details: { include: { detail: { @@ -74,6 +78,7 @@ const labCaseListInclude = { mimeType: true, sizeBytes: true, createdAt: true, + detailClientKey: true, }, }, }, @@ -92,6 +97,8 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ @Injectable() export class CasesService { + private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases'); + constructor( private readonly prisma: PrismaService, private readonly prosthesisCatalog: ProsthesisCatalogService, @@ -103,7 +110,7 @@ export class CasesService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -131,6 +138,7 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, + partnerClinic: { select: { id: true, name: true } }, tasks: { select: { id: true, @@ -141,7 +149,7 @@ export class CasesService { }, }, }, - orderBy: [{ sentAt: 'desc' }], + orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: 'desc' }], skip, take: limit, }), @@ -178,11 +186,9 @@ export class CasesService { const [clinicRows, taskRows] = await Promise.all([ this.prisma.labCase.findMany({ - where: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + where: this.visibleToLabWhere(labOrganizationId), select: { + partnerClinic: { select: { id: true, name: true } }, treatment: { select: { organization: { select: { id: true, name: true } }, @@ -192,10 +198,7 @@ export class CasesService { }), this.prisma.labCaseTask.findMany({ where: { - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, select: { prosthesisTypeCode: true }, distinct: ['prosthesisTypeCode'], @@ -204,7 +207,12 @@ export class CasesService { const clinicsById = new Map(); for (const row of clinicRows) { - clinicsById.set(row.treatment.organization.id, row.treatment.organization); + if (row.treatment?.organization) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + } + if (row.partnerClinic) { + clinicsById.set(row.partnerClinic.id, row.partnerClinic); + } } const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode)); @@ -300,7 +308,7 @@ export class CasesService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return { @@ -309,6 +317,372 @@ export class CasesService { }; } + async listLinkedClinics(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findMany({ + where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE }, + include: { + organizationB: { select: { id: true, name: true, type: { select: { name: true } } } }, + }, + }), + this.prisma.organizationLink.findMany({ + where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE }, + include: { + organizationA: { select: { id: true, name: true, type: { select: { name: true } } } }, + }, + }), + ]); + + const data = [ + ...linksA.map((l) => ({ + id: l.organizationB.id, + name: l.organizationB.name, + type: l.organizationB.type.name, + })), + ...linksB.map((l) => ({ + id: l.organizationA.id, + name: l.organizationA.name, + type: l.organizationA.type.name, + })), + ] + .filter((o) => o.type === 'CLINIC') + .map(({ id, name }) => ({ id, name, active: true })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { success: true, data }; + } + + async createInternal( + dto: CreateLabInternalCaseDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); + + const created = await this.prisma.labCase.create({ + data: { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + referringClinicName: dto.referringClinicName?.trim() || null, + referringDentistName: dto.referringDentistName?.trim() || null, + patientDisplayName: dto.patientDisplayName?.trim() || null, + patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, + partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, + dueDate: dto.dueDate ? new Date(dto.dueDate) : null, + sortOrder: 0, + }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(created, localeInput) }; + } + + async updateInternal( + labCaseId: string, + dto: UpdateLabInternalCaseDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { id: true, startedAt: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); + } + + await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); + + const saved = await this.prisma.$transaction(async (tx) => { + await tx.labCase.update({ + where: { id: labCaseId }, + data: { + referringClinicName: dto.referringClinicName?.trim() || null, + referringDentistName: dto.referringDentistName?.trim() || null, + patientDisplayName: dto.patientDisplayName?.trim() || null, + patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, + partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, + dueDate: + dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null, + }, + }); + + if (dto.lines) { + const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[]; + await tx.labCaseLine.deleteMany({ + where: { + labCaseId, + ...(keepIds.length ? { id: { notIn: keepIds } } : {}), + }, + }); + + for (const [index, line] of dto.lines.entries()) { + if (line.id) { + const owned = await tx.labCaseLine.findFirst({ + where: { id: line.id, labCaseId }, + select: { id: true }, + }); + if (!owned) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST); + } + } + + const teeth = normalizeTeeth(line.teeth); + const row = line.id + ? await tx.labCaseLine.update({ + where: { id: line.id }, + data: { + clientKey: line.clientId, + sortOrder: index, + teeth, + toothSelectionGroups: (line.toothSelectionGroups ?? + undefined) as Prisma.InputJsonValue | undefined, + comment: line.comment?.trim() || null, + }, + }) + : await tx.labCaseLine.create({ + data: { + labCaseId, + clientKey: line.clientId, + sortOrder: index, + treatmentType: 'prosthesis', + teeth, + toothSelectionGroups: (line.toothSelectionGroups ?? + undefined) as Prisma.InputJsonValue | undefined, + comment: line.comment?.trim() || null, + }, + }); + + await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } }); + if (line.toothProsthesis?.length) { + for (const tp of line.toothProsthesis) { + this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode); + } + await tx.labCaseToothProsthesis.createMany({ + data: line.toothProsthesis.map((tp) => ({ + labCaseId, + lineId: row.id, + sourceKey: row.id, + treatmentType: 'prosthesis', + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId?.trim() || '', + })), + }); + } + + if (line.attachmentIds) { + const lineAttachments = await tx.treatmentDetailAttachment.findMany({ + where: { + detailClientKey: line.clientId, + labCaseLinks: { some: { labCaseId } }, + }, + select: { id: true }, + }); + const keep = new Set(line.attachmentIds); + const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id)); + if (removeIds.length) { + await tx.labCaseAttachment.deleteMany({ + where: { labCaseId, attachmentId: { in: removeIds } }, + }); + } + if (line.attachmentIds.length) { + await tx.labCaseAttachment.createMany({ + data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })), + skipDuplicates: true, + }); + } + } + } + } + + return tx.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + }); + + return { success: true, data: await this.mapLabCaseDetail(saved, localeInput) }; + } + + async startInternal( + labCaseId: string, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + include: { lines: true, toothProsthesis: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); + } + if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) { + throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST); + } + const hasClient = + Boolean(existing.referringClinicName?.trim()) || + Boolean(existing.patientDisplayName?.trim()) || + Boolean(existing.partnerClinicOrganizationId); + if (!hasClient) { + throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST); + } + + await this.prisma.$transaction(async (tx) => { + await tx.labCase.update({ + where: { id: labCaseId }, + data: { startedAt: new Date() }, + }); + await generateLabCaseTasks(tx, labCaseId, localeInput); + }); + + const refreshed = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) }; + } + + async deleteInternalDraft( + labCaseId: string, + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { + id: true, + startedAt: true, + attachments: { include: { attachment: { select: { id: true, storagePath: true } } } }, + }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); + } + + const orphanAttachments = existing.attachments.map((row) => row.attachment); + const orphanIds = orphanAttachments.map((row) => row.id); + + await this.prisma.$transaction(async (tx) => { + await tx.labCase.delete({ where: { id: labCaseId } }); + if (orphanIds.length > 0) { + await tx.treatmentDetailAttachment.deleteMany({ + where: { id: { in: orphanIds }, detailId: null }, + }); + } + }); + + for (const attachment of orphanAttachments) { + try { + if (attachment.storagePath && existsSync(attachment.storagePath)) { + unlinkSync(attachment.storagePath); + } + } catch { + // Best-effort disk cleanup; the draft row is already gone. + } + } + + return { success: true }; + } + + async uploadInternalAttachments( + labCaseId: string, + lineClientKey: string, + files: Express.Multer.File[], + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { id: true, startedAt: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); + } + if (!lineClientKey?.trim()) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); + } + if (!files?.length) { + throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); + } + + const orgDir = join(this.uploadRoot, labOrganizationId); + mkdirSync(orgDir, { recursive: true }); + + const created: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }[] = []; + + for (const file of files) { + const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; + const storagePath = join(orgDir, storageName); + const { writeFileSync } = await import('fs'); + writeFileSync(storagePath, file.buffer); + + const attachment = await this.prisma.treatmentDetailAttachment.create({ + data: { + detailClientKey: lineClientKey, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + storagePath, + }, + }); + await this.prisma.labCaseAttachment.create({ + data: { labCaseId, attachmentId: attachment.id }, + }); + created.push({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }); + } + + return { success: true, data: created }; + } + async getOne( labCaseId: string, labOrganizationId: string, @@ -320,14 +694,13 @@ export class CasesService { const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, include: labCaseListInclude, }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; @@ -345,10 +718,7 @@ export class CasesService { where: { labCaseId, attachmentId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleToLabWhere(labOrganizationId), }, include: { attachment: { select: { storagePath: true, fileName: true, mimeType: true } }, @@ -356,11 +726,11 @@ export class CasesService { }); if (!link?.attachment) { - throw new NotFoundException('Attachment not found'); + throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!existsSync(link.attachment.storagePath)) { - throw new NotFoundException('Attachment file is missing on disk'); + throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND); } return { @@ -382,14 +752,13 @@ export class CasesService { const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true, isImportant: true }, }); if (!existing) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.prisma.labCase.update({ @@ -433,14 +802,13 @@ export class CasesService { const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true }, }); if (!existing) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } const externalCode = @@ -501,16 +869,13 @@ export class CasesService { where: { id: taskId, labCaseId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, select: { id: true }, }); if (!task) { - throw new NotFoundException('Task not found'); + throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND); } const assigneeUserId = dto.assigneeUserId ?? null; @@ -530,7 +895,7 @@ export class CasesService { const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')); if (!canReceive) { - throw new BadRequestException('Selected user cannot be assigned tasks'); + throw new AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST); } } @@ -577,7 +942,7 @@ export class CasesService { if (query.sentFrom) { const from = new Date(query.sentFrom); if (Number.isNaN(from.getTime())) { - throw new BadRequestException('Invalid sentFrom date'); + throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST); } sentAtFilter.gte = from; } @@ -585,26 +950,50 @@ export class CasesService { if (query.sentTo) { const to = new Date(query.sentTo); if (Number.isNaN(to.getTime())) { - throw new BadRequestException('Invalid sentTo date'); + throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST); } to.setHours(23, 59, 59, 999); sentAtFilter.lte = to; } - return { + const received: Prisma.LabCaseWhereInput = { sentAt: sentAtFilter, sends: { some: { organizationId: labOrganizationId } }, ...(query.clinicOrganizationId ? { treatment: { organizationId: query.clinicOrganizationId } } : {}), - ...(query.prosthesisTypeCode + }; + + const ownedInternal: Prisma.LabCaseWhereInput = { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + ...(query.clinicOrganizationId ? { - tasks: { - some: { prosthesisTypeCode: query.prosthesisTypeCode }, - }, + OR: [ + { partnerClinicOrganizationId: query.clinicOrganizationId }, + ], } : {}), - ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}), + }; + + if (query.sentFrom || query.sentTo) { + ownedInternal.startedAt = { ...sentAtFilter }; + } + + return { + AND: [ + { OR: [received, ownedInternal] }, + ...(query.prosthesisTypeCode + ? [ + { + tasks: { + some: { prosthesisTypeCode: query.prosthesisTypeCode }, + }, + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []), + ], }; } @@ -625,6 +1014,10 @@ export class CasesService { organization: { name: { contains: q, mode: 'insensitive' } }, }, }, + { referringClinicName: { contains: q, mode: 'insensitive' } }, + { referringDentistName: { contains: q, mode: 'insensitive' } }, + { patientDisplayName: { contains: q, mode: 'insensitive' } }, + { patientDisplayMobile: { contains: q, mode: 'insensitive' } }, ]; const normalized = normalizeMobile(q); @@ -637,15 +1030,49 @@ export class CasesService { return { OR: orConditions }; } + private resolveClinicAndPatient(lc: { + origin?: LabCaseOrigin; + referringClinicName?: string | null; + patientDisplayName?: string | null; + patientDisplayMobile?: string | null; + partnerClinic?: { id: string; name: string } | null; + treatment?: { + organization: { id: string; name: string }; + patient: { id: string; firstName: string; lastName: string; mobile: string }; + } | null; + }) { + const clinic = + lc.treatment?.organization ?? + lc.partnerClinic ?? { + id: '', + name: lc.referringClinicName?.trim() || '', + }; + const displayName = lc.patientDisplayName?.trim() || ''; + const [firstName, ...rest] = displayName.split(/\s+/); + const patient = lc.treatment?.patient ?? { + id: '', + firstName: firstName || displayName || clinic.name || '—', + lastName: rest.join(' '), + mobile: lc.patientDisplayMobile?.trim() || '', + }; + return { clinic, patient }; + } + private mapLabCaseListItem(lc: { id: string; sentAt: Date | null; + startedAt?: Date | null; dueDate: Date | null; isImportant: boolean; + origin?: LabCaseOrigin; + referringClinicName?: string | null; + patientDisplayName?: string | null; + patientDisplayMobile?: string | null; + partnerClinic?: { id: string; name: string } | null; treatment: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; - }; + } | null; tasks: Array<{ id: string; status: LabTaskStatus; @@ -655,20 +1082,18 @@ export class CasesService { }) { const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks); const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; + const { clinic, patient } = this.resolveClinicAndPatient(lc); return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, + startedAt: lc.startedAt?.toISOString() ?? null, + origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH, dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, - clinic: lc.treatment.organization, - patient: { - id: lc.treatment.patient.id, - firstName: lc.treatment.patient.firstName, - lastName: lc.treatment.patient.lastName, - mobile: lc.treatment.patient.mobile, - }, + clinic, + patient, prosthesisGroups, taskProgress: { completed: completedTasks, @@ -682,8 +1107,10 @@ export class CasesService { localeInput?: string | null, ) { const locale = normalizeCatalogLocale(localeInput); - const treatmentType = lc.details[0]?.detail.treatmentType ?? null; + const treatmentType = + lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null; const link = lc.details[0]; + const { clinic, patient } = this.resolveClinicAndPatient(lc); const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))]; const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, @@ -705,21 +1132,44 @@ export class CasesService { isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, externalCode: lc.externalCode ?? null, + origin: lc.origin, + startedAt: lc.startedAt?.toISOString() ?? null, + referringClinicName: lc.referringClinicName, + referringDentistName: lc.referringDentistName, + patientDisplayName: lc.patientDisplayName, + patientDisplayMobile: lc.patientDisplayMobile, + partnerClinicOrganizationId: lc.partnerClinicOrganizationId, shareUrl, - clinic: lc.treatment.organization, - patient: lc.treatment.patient, - appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, + clinic, + patient, + appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null, treatmentType, - detail: link + detail: link?.detail ? { id: link.detail.id, treatmentType: link.detail.treatmentType, teeth: normalizeTeeth(link.detail.teeth), comment: link.detail.comment, } - : null, + : lc.lines[0] + ? { + id: lc.lines[0].id, + treatmentType: lc.lines[0].treatmentType, + teeth: normalizeTeeth(lc.lines[0].teeth), + comment: lc.lines[0].comment, + } + : null, + lines: lc.lines.map((line) => ({ + id: line.id, + clientId: line.clientKey ?? line.id, + treatmentType: line.treatmentType, + teeth: normalizeTeeth(line.teeth), + toothSelectionGroups: line.toothSelectionGroups, + comment: line.comment, + })), toothProsthesis: lc.toothProsthesis.map((row) => ({ treatmentDetailId: row.treatmentDetailId, + lineId: row.lineId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId: row.selectionGroupId?.trim() || undefined, @@ -730,6 +1180,7 @@ export class CasesService { mimeType: row.attachment.mimeType, sizeBytes: row.attachment.sizeBytes, createdAt: row.attachment.createdAt.toISOString(), + detailClientKey: row.attachment.detailClientKey, })), sends: lc.sends.map((s) => ({ organizationId: s.organizationId, @@ -807,9 +1258,9 @@ export class CasesService { for (const task of tasks) { const selectionGroupId = task.selectionGroupId ?? ''; - const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`; + const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -840,7 +1291,7 @@ export class CasesService { ) { return { id: task.id, - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -871,30 +1322,96 @@ export class CasesService { }; } + private visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + ], + }; + } + + private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + startedAt: { not: null }, + }, + ], + }; + } + + private async assertPartnerClinic( + partnerId: string | null | undefined, + labOrganizationId: string, + ) { + if (!partnerId) return; + const org = await this.prisma.organization.findUnique({ + where: { id: partnerId }, + select: { id: true, type: { select: { name: true } } }, + }); + if (!org || org.type.name !== 'CLINIC') { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findFirst({ + where: { + organizationAId: labOrganizationId, + organizationBId: partnerId, + status: LinkStatus.ACTIVE, + }, + select: { id: true }, + }), + this.prisma.organizationLink.findFirst({ + where: { + organizationAId: partnerId, + organizationBId: labOrganizationId, + status: LinkStatus.ACTIVE, + }, + select: { id: true }, + }), + ]); + if (!linksA && !linksB) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + } + private async assertCanReadCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) return; const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) { return; } - throw new ForbiddenException('You do not have access to cases'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } private async assertCanEditCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (m.isOwner) return; const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_CASES_EDIT')) { return; } - throw new ForbiddenException('You cannot update cases'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } private async getMembership(userId: string, organizationId: string) { diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index a553caf..380b325 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,5 +1,18 @@ -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; export class UpdateLabCaseImportantDto { @IsBoolean() @@ -53,3 +66,105 @@ export class ListLabCasesDto { @Max(100) limit = 20; } + +export class ToothSelectionGroupDto { + @IsString() + @MaxLength(64) + groupId: string; + + @IsString() + kind: string; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; +} + +export class LabInternalToothProsthesisDto { + @IsString() + @MaxLength(8) + tooth: string; + + @IsString() + @MaxLength(64) + prosthesisTypeCode: string; + + @IsOptional() + @IsString() + @MaxLength(64) + selectionGroupId?: string; +} + +export class LabInternalCaseLineDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ToothSelectionGroupDto) + toothSelectionGroups?: ToothSelectionGroupDto[]; + + @IsOptional() + @IsString() + @MaxLength(5000) + comment?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabInternalToothProsthesisDto) + toothProsthesis?: LabInternalToothProsthesisDto[]; + + @IsOptional() + @IsArray() + @IsUUID(undefined, { each: true }) + attachmentIds?: string[]; +} + +export class CreateLabInternalCaseDto { + @IsOptional() + @IsString() + @MaxLength(200) + referringClinicName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + referringDentistName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + patientDisplayName?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + patientDisplayMobile?: string; + + @IsOptional() + @IsUUID() + partnerClinicOrganizationId?: string | null; + + @IsOptional() + @IsDateString() + dueDate?: string | null; +} + +export class UpdateLabInternalCaseDto extends CreateLabInternalCaseDto { + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabInternalCaseLineDto) + lines?: LabInternalCaseLineDto[]; +} diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 50c18d1..2e681fa 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -1,7 +1,6 @@ import { HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; import { CatalogEntityKind, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -129,9 +128,16 @@ export class LabCaseAccessService { dueDate: labCase.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks), isImportant: labCase.isImportant, - clinic: labCase.treatment.organization, + clinic: labCase.treatment?.organization ?? { + id: '', + name: '', + }, lab: labCase.sends[0]?.organization ?? null, - patient: labCase.treatment.patient, + patient: labCase.treatment?.patient ?? { + id: '', + firstName: '', + lastName: '', + }, prosthesisGroups, }, }; @@ -230,7 +236,7 @@ export class LabCaseAccessService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return labCase; @@ -241,7 +247,7 @@ export class LabCaseAccessService { actorUserId: string, organizationId: string, ): Promise { - const clinicOrgId = labCase.treatment.organization.id; + const clinicOrgId = labCase.treatment?.organization.id; const labOrgId = labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null; @@ -267,13 +273,13 @@ export class LabCaseAccessService { return { kind: 'denied' }; } - if (organizationId === clinicOrgId) { + if (clinicOrgId && organizationId === clinicOrgId) { const membership = await this.getMembership(actorUserId, clinicOrgId); if (!membership) return { kind: 'denied' }; if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) { return { kind: 'denied' }; } - if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { + if (!labCase.treatment || !isActorTreatmentProvider(labCase.treatment, actorUserId)) { return { kind: 'denied' }; } return { diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts index 935dbcf..99b114f 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -8,7 +8,9 @@ import { generateLabCaseTasks } from './lab-case-task.generator'; function buildMockTx(options: { existingCount?: number; toothProsthesisRows: Array<{ - treatmentDetailId: string; + treatmentDetailId?: string | null; + lineId?: string | null; + sourceKey?: string; tooth: string; prosthesisTypeCode: string; selectionGroupId?: string; @@ -33,15 +35,27 @@ function buildMockTx(options: { labCaseToothProsthesis: { findMany: jest.fn().mockResolvedValue( options.toothProsthesisRows.map((row) => ({ - treatmentDetailId: row.treatmentDetailId, + treatmentDetailId: row.treatmentDetailId ?? null, + lineId: row.lineId ?? null, + sourceKey: row.sourceKey ?? row.treatmentDetailId ?? row.lineId ?? '', + treatmentType: row.treatmentType ?? 'prosthesis', tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId: row.selectionGroupId ?? '', - detail: { - id: row.treatmentDetailId, - treatmentType: row.treatmentType ?? 'prosthesis', - toothSelectionGroups: null, - }, + detail: row.treatmentDetailId + ? { + id: row.treatmentDetailId, + treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, + } + : null, + line: row.lineId + ? { + id: row.lineId, + treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, + } + : null, })), ), }, @@ -219,4 +233,34 @@ describe('generateLabCaseTasks', () => { expect(count).toBe(0); expect(tx.labCaseTask.createMany).not.toHaveBeenCalled(); }); + + it('creates tasks from lab-origin lines using sourceKey and lineId', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + lineId: 'line-1', + sourceKey: 'line-1', + tooth: '11', + prosthesisTypeCode: 'pfm_crown', + }, + { + lineId: 'line-1', + sourceKey: 'line-1', + tooth: '21', + prosthesisTypeCode: 'pfm_crown', + }, + ], + prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en'); + expect(count).toBe(pfmSteps.length); + expect(created[0]).toMatchObject({ + lineId: 'line-1', + sourceKey: 'line-1', + treatmentDetailId: null, + teeth: ['11', '21'], + }); + }); }); diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 063056f..5caa5c0 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -19,6 +19,7 @@ export async function generateLabCaseTasks( where: { labCaseId }, include: { detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, + line: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, }, }); @@ -64,7 +65,9 @@ export async function generateLabCaseTasks( const groups = new Map< string, { - treatmentDetailId: string; + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; treatmentType: string; prosthesisTypeCode: string; selectionGroupId: string; @@ -73,11 +76,23 @@ export async function generateLabCaseTasks( >(); for (const row of toothProsthesisRows) { - const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const sourceKey = + row.sourceKey || + row.treatmentDetailId || + row.lineId || + ''; + if (!sourceKey) continue; + const key = `${sourceKey}::${row.prosthesisTypeCode}`; const selectionGroupId = row.selectionGroupId?.trim() || ''; const group = groups.get(key) ?? { + sourceKey, treatmentDetailId: row.treatmentDetailId, - treatmentType: row.detail.treatmentType, + lineId: row.lineId, + treatmentType: + row.detail?.treatmentType ?? + row.line?.treatmentType ?? + row.treatmentType ?? + 'prosthesis', prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId, teeth: [], @@ -104,6 +119,8 @@ export async function generateLabCaseTasks( taskRows.push({ labCaseId, treatmentDetailId: group.treatmentDetailId, + lineId: group.lineId, + sourceKey: group.sourceKey, teeth, treatmentType: group.treatmentType, prosthesisTypeCode: group.prosthesisTypeCode, diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts index 0328c03..a0511be 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts @@ -31,7 +31,7 @@ export class LabCaseCommentsController { @Get(':caseId') @ApiOperation({ summary: 'List comments for a lab case (lab side)' }) list(@Param('caseId') caseId: string, @Req() req) { - return this.service.listForLab(caseId, this.orgId(req), req.user.id); + return this.service.listForLabViewer(caseId, this.orgId(req), req.user.id); } @Post(':caseId') diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index 794f8e5..9c994bc 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -1,9 +1,9 @@ import { - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; -import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; +import { AppException, ErrorCode } from '../../common/errors'; +import { LabCaseCommentSide, LabCaseActivityType, LabCaseOrigin, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -108,11 +108,11 @@ export class LabCaseCommentsService { select: { id: true, labCaseId: true, authorSide: true }, }); if (!comment) { - throw new NotFoundException('Comment not found'); + throw new AppException(ErrorCode.COMMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId); if (comment.authorSide !== LabCaseCommentSide.LAB) { - throw new ForbiddenException('Only lab comments can change visibility'); + throw new AppException(ErrorCode.COMMENT_VISIBILITY_LAB_ONLY, HttpStatus.FORBIDDEN); } const updated = await this.prisma.labCaseComment.update({ where: { id: commentId }, @@ -278,7 +278,7 @@ export class LabCaseCommentsService { await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId); const membership = await this.getLabMembership(actorUserId, labOrganizationId); if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { - throw new ForbiddenException('You do not have access to task comments'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN); } } @@ -290,13 +290,21 @@ export class LabCaseCommentsService { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + ], }, select: { id: true }, }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } const membership = await this.getLabMembership(actorUserId, labOrganizationId); @@ -304,7 +312,7 @@ export class LabCaseCommentsService { !hasEffectivePermission(membership, 'TAB_TASKS_READ') && !hasEffectivePermission(membership, 'TAB_TASKS_EDIT') ) { - throw new ForbiddenException('You do not have access to tasks'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN); } } @@ -321,7 +329,7 @@ export class LabCaseCommentsService { }, }); if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } return membership; } @@ -340,7 +348,7 @@ export class LabCaseCommentsService { select: { id: true }, }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } } @@ -360,7 +368,7 @@ export class LabCaseCommentsService { select: { id: true }, }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } const membership = await this.prisma.membership.findFirst({ @@ -375,7 +383,7 @@ export class LabCaseCommentsService { }, }); if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if ( hasEffectivePermission(membership, 'TAB_TREATMENT_READ') || @@ -383,7 +391,7 @@ export class LabCaseCommentsService { ) { return; } - throw new ForbiddenException('You do not have access to treatment cases'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN); } private async labOrgIdForCase(caseId: string): Promise { @@ -398,8 +406,11 @@ export class LabCaseCommentsService { private async clinicOrgIdForCase(caseId: string): Promise { const labCase = await this.prisma.labCase.findUnique({ where: { id: caseId }, - select: { treatment: { select: { organizationId: true } } }, + select: { + partnerClinicOrganizationId: true, + treatment: { select: { organizationId: true } }, + }, }); - return labCase?.treatment.organizationId ?? null; + return labCase?.treatment?.organizationId ?? labCase?.partnerClinicOrganizationId ?? null; } } diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index d19bd94..ca0f060 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -1,13 +1,9 @@ import { - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; -import { - LabCaseActivityType, - LabCaseTabReadTarget, - Prisma, -} from '@prisma/client'; +import { AppException, ErrorCode } from '../../common/errors'; +import { LabCaseActivityType, LabCaseOrigin, LabCaseTabReadTarget, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { @@ -49,7 +45,7 @@ export class LabCaseActivityService { include: { type: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.assertMembership(userId, organizationId); @@ -224,7 +220,7 @@ export class LabCaseActivityService { include: { type: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.assertCanAccessCase(userId, organizationId, labCaseId); @@ -454,7 +450,7 @@ export class LabCaseActivityService { }, }); if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } } @@ -468,7 +464,7 @@ export class LabCaseActivityService { include: { type: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } const labCase = await this.prisma.labCase.findFirst({ @@ -476,8 +472,16 @@ export class LabCaseActivityService { org.type.name === 'LAB' ? { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId } }, + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: organizationId, + }, + ], } : { id: labCaseId, @@ -491,7 +495,7 @@ export class LabCaseActivityService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (org.type.name === 'LAB') { @@ -513,7 +517,7 @@ export class LabCaseActivityService { hasEffectivePermission(membership, 'TAB_TASKS_READ') ) ) { - throw new ForbiddenException('You do not have access to this case'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } return; } @@ -536,7 +540,7 @@ export class LabCaseActivityService { hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT') ) ) { - throw new ForbiddenException('You do not have access to this case'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN); } } } diff --git a/backend/src/modules/notifications/user-notification.service.ts b/backend/src/modules/notifications/user-notification.service.ts index a816325..48c7f57 100644 --- a/backend/src/modules/notifications/user-notification.service.ts +++ b/backend/src/modules/notifications/user-notification.service.ts @@ -54,6 +54,10 @@ export class UserNotificationService { select: { id: true, destinationOrganizationId: true, + origin: true, + referringClinicName: true, + patientDisplayName: true, + partnerClinic: { select: { name: true } }, treatment: { select: { organization: { select: { name: true } }, @@ -70,7 +74,7 @@ export class UserNotificationService { }, }); - if (!labCase?.treatment) { + if (!labCase) { return { labCaseId, ...(extra ?? {}) }; } @@ -92,16 +96,21 @@ export class UserNotificationService { ), ]; - const patient = labCase.treatment.patient; - const patientName = `${patient.firstName} ${patient.lastName}`.trim(); + const patient = labCase.treatment?.patient; + const patientName = patient + ? `${patient.firstName} ${patient.lastName}`.trim() + : labCase.patientDisplayName?.trim() || ''; + const clinicName = + labCase.treatment?.organization.name ?? + labCase.partnerClinic?.name ?? + labCase.referringClinicName?.trim() ?? + ''; - // Caller ids (taskId, commentId, …) come from `extra`; denormalized display - // fields must win so they are never overwritten by a thin emit payload. return { ...(extra ?? {}), labCaseId, patientName, - clinicName: labCase.treatment.organization.name, + clinicName, labName, prosthesisTypeCodes, }; @@ -340,8 +349,9 @@ export class UserNotificationService { }, }); if (!labCase?.treatment) return []; + const treatment = labCase.treatment; userIds = userIds.filter((userId) => - isActorTreatmentProvider(labCase.treatment, userId), + isActorTreatmentProvider(treatment, userId), ); } diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index c4dcd59..1d6de8a 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -1,10 +1,5 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { LinkStatus, UserNotificationType } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; @@ -42,7 +37,7 @@ export class OrganizationService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -50,7 +45,7 @@ export class OrganizationService { async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const targetType = this.getCounterpartType(actor.organization.type.name); @@ -88,7 +83,7 @@ export class OrganizationService { async list(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } // Open outbound invitations keyed by placeholder/real invited org id — lets UI show copy-invite @@ -174,7 +169,7 @@ export class OrganizationService { async countIncomingPendingConnections(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const links = await this.prisma.organizationLink.findMany({ @@ -196,7 +191,7 @@ export class OrganizationService { async listInvitationHistory(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const invitations = await this.prisma.organizationInvitation.findMany({ @@ -227,10 +222,10 @@ export class OrganizationService { ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } if (dto.targetOrganizationId === organizationId) { - throw new BadRequestException('You cannot link organization to itself'); + throw new AppException(ErrorCode.ORG_CANNOT_LINK_SELF, HttpStatus.BAD_REQUEST); } const sourceType = actor.organization.type.name; @@ -240,13 +235,13 @@ export class OrganizationService { select: { id: true, type: true, planId: true }, }); if (!target) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } if (target.type.name !== targetType) { - throw new BadRequestException(`You can only link to ${targetType} organizations`); + throw new AppException(ErrorCode.ORG_LINK_WRONG_TYPE, HttpStatus.BAD_REQUEST); } if (!target.planId) { - throw new BadRequestException('Target organization does not have an active subscription'); + throw new AppException(ErrorCode.ORG_TARGET_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST); } const [aId, bId] = @@ -258,7 +253,7 @@ export class OrganizationService { where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, }); if (existing) { - throw new ConflictException('Link already exists for these organizations'); + throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT); } const created = await this.prisma.organizationLink.create({ @@ -295,7 +290,7 @@ export class OrganizationService { ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const connection = await this.prisma.organizationLink.findFirst({ @@ -305,15 +300,15 @@ export class OrganizationService { }, }); if (!connection) { - throw new NotFoundException('Connection request not found'); + throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND); } if (connection.status !== LinkStatus.PENDING) { - throw new BadRequestException('Only pending connection requests can be responded to'); + throw new AppException(ErrorCode.ORG_REQUEST_NOT_PENDING, HttpStatus.BAD_REQUEST); } const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes); if (requesterOrgId && requesterOrgId === organizationId) { - throw new ForbiddenException('You cannot respond to your own connection request'); + throw new AppException(ErrorCode.ORG_CANNOT_RESPOND_OWN, HttpStatus.FORBIDDEN); } const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED; @@ -335,7 +330,7 @@ export class OrganizationService { async deleteConnection(userId: string, organizationId: string, connectionId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const connection = await this.prisma.organizationLink.findFirst({ @@ -347,7 +342,7 @@ export class OrganizationService { select: { id: true }, }); if (!connection) { - throw new NotFoundException('Connected organization not found'); + throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.prisma.organizationLink.delete({ where: { id: connection.id } }); @@ -367,7 +362,7 @@ export class OrganizationService { ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const { clinicOrganizationId, labOrganizationId, counterpart } = @@ -397,7 +392,7 @@ export class OrganizationService { ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const { clinicOrganizationId, labOrganizationId, counterpart } = @@ -459,11 +454,11 @@ export class OrganizationService { ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor); if (parties.clinicOrganizationId !== organizationId) { - throw new ForbiddenException('Only the clinic can comment on this case'); + throw new AppException(ErrorCode.ORG_ONLY_CLINIC_COMMENT, HttpStatus.FORBIDDEN); } return parties; } @@ -472,7 +467,7 @@ export class OrganizationService { async getInvitationLink(userId: string, organizationId: string, invitationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const invitation = await this.prisma.organizationInvitation.findFirst({ @@ -487,13 +482,13 @@ export class OrganizationService { }, }); if (!invitation) { - throw new NotFoundException('Invitation not found'); + throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (invitation.acceptedAt) { - throw new BadRequestException('This invitation has already been accepted'); + throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST); } if (invitation.revokedAt) { - throw new BadRequestException('This invitation is no longer valid'); + throw new AppException(ErrorCode.ORG_INVITE_INVALID, HttpStatus.BAD_REQUEST); } const plainToken = this.generateInviteToken(); @@ -522,7 +517,7 @@ export class OrganizationService { async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { - throw new ForbiddenException('You do not have permission to manage organizations'); + throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN); } const ownerEmail = dto.ownerEmail.trim().toLowerCase(); @@ -539,9 +534,7 @@ export class OrganizationService { select: { id: true }, }); if (existingOwnerWithPlan) { - throw new BadRequestException( - 'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.', - ); + throw new AppException(ErrorCode.ORG_INVITE_OWNER_HAS_ORG, HttpStatus.BAD_REQUEST); } const invitation = await this.prisma.$transaction(async (tx) => { @@ -587,7 +580,7 @@ export class OrganizationService { where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, }); if (existingLink?.status === LinkStatus.ACTIVE) { - throw new ConflictException('These organizations are already linked'); + throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT); } // Pre-create connection so inviter sees one pending row; acceptInvite() flips to ACTIVE. @@ -660,13 +653,11 @@ export class OrganizationService { async acceptInvite(dto: AcceptOrganizationInviteDto) { const invitation = await this.findValidInvitation(dto.token); if (invitation.acceptedAt) { - throw new BadRequestException('This invitation has already been accepted'); + throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST); } if (dto.organizationType !== invitation.invitedOrganizationType) { - throw new BadRequestException( - `Organization type must be ${invitation.invitedOrganizationType} for this invitation`, - ); + throw new AppException(ErrorCode.ORG_INVITE_TYPE_MISMATCH, HttpStatus.BAD_REQUEST); } const organizationEmail = dto.organizationEmail.trim().toLowerCase(); @@ -788,7 +779,7 @@ export class OrganizationService { }); if (!link) { - throw new NotFoundException('Connected organization not found'); + throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND); } const counterpart = @@ -797,7 +788,7 @@ export class OrganizationService { const orgType = actor.organization.type.name; if (orgType === 'CLINIC') { if (counterpart.type.name !== 'LAB') { - throw new BadRequestException('Counterpart organization is not a lab'); + throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_LAB, HttpStatus.BAD_REQUEST); } return { clinicOrganizationId: organizationId, @@ -808,7 +799,7 @@ export class OrganizationService { if (orgType === 'LAB') { if (counterpart.type.name !== 'CLINIC') { - throw new BadRequestException('Counterpart organization is not a clinic'); + throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_CLINIC, HttpStatus.BAD_REQUEST); } return { clinicOrganizationId: counterpart.id, @@ -817,7 +808,7 @@ export class OrganizationService { }; } - throw new BadRequestException('Unknown organization type'); + throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST); } private async getActorMembership(userId: string, organizationId: string) { @@ -846,7 +837,7 @@ export class OrganizationService { private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' { if (orgType === 'CLINIC') return 'LAB'; if (orgType === 'LAB') return 'CLINIC'; - throw new BadRequestException('Unknown organization type'); + throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST); } private mapInvitationStatus( @@ -898,13 +889,13 @@ export class OrganizationService { }, }); if (!invitation) { - throw new NotFoundException('Invitation not found'); + throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (invitation.revokedAt) { - throw new BadRequestException('Invitation has been revoked'); + throw new AppException(ErrorCode.ORG_INVITE_REVOKED, HttpStatus.BAD_REQUEST); } if (invitation.expiresAt.getTime() <= Date.now()) { - throw new BadRequestException('Invitation has expired'); + throw new AppException(ErrorCode.ORG_INVITE_EXPIRED, HttpStatus.BAD_REQUEST); } return invitation; } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index a573acd..050da08 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -43,9 +43,10 @@ export class PatientsService { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; - const where = q?.trim() - ? this.buildSearchWhere(q.trim()) - : {}; + const where = { + isWalkIn: false, + ...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}), + }; const [items, total] = await Promise.all([ this.prisma.patient.findMany({ @@ -76,8 +77,8 @@ export class PatientsService { where: { id }, }); - if (!patient) { - throw new NotFoundException('Patient not found'); + if (!patient || patient.isWalkIn) { + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } return { success: true, data: patient }; @@ -85,6 +86,13 @@ export class PatientsService { 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); + } const data: { firstName?: string; @@ -116,12 +124,12 @@ export class PatientsService { : null; } - const patient = await this.prisma.patient.update({ + const updated = await this.prisma.patient.update({ where: { id }, data, }); - return { success: true, data: patient }; + return { success: true, data: updated }; } async listAppointments( @@ -162,7 +170,7 @@ export class PatientsService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -240,7 +248,7 @@ export class PatientsService { }); if (!patient) { - throw new NotFoundException('Patient not found'); + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } } diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index 7a0f0d2..f042165 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -1,4 +1,5 @@ -import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common'; +import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { CatalogEntityKind } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { @@ -60,7 +61,7 @@ export class ProsthesisCatalogService implements OnModuleInit { assertKnownProsthesisType(code: string): void { this.ensureLoaded(); if (!this.byCode.has(code)) { - throw new BadRequestException(`Unknown prosthesis type: ${code}`); + throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST); } } diff --git a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts index 68c508a..1c85f30 100644 --- a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts +++ b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts @@ -5,10 +5,14 @@ import { IsBoolean, IsInt, IsOptional, + IsString, + Matches, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class WorkingHoursBlockDto { @IsInt() @@ -41,4 +45,9 @@ export class UpsertWorkingHoursDto { @ValidateNested({ each: true }) @Type(() => WorkingHoursBlockDto) blocks: WorkingHoursBlockDto[]; + + @IsString() + @MaxLength(64) + @Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID }) + timeZone: string; } diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts index 38284ff..7be5f13 100644 --- a/backend/src/modules/staff/staff-working-hours.service.ts +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { appointmentWithinWorkingHours, @@ -12,10 +7,12 @@ import { validateWorkingHoursBlocks, type WorkingHoursBlockInput, } from '../../common/working-hours'; +import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time'; import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; import { hasEffectivePermission, } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; @Injectable() export class StaffWorkingHoursService { @@ -124,14 +121,16 @@ export class StaffWorkingHoursService { ) { const validationError = validateWorkingHoursBlocks(dto.blocks); if (validationError) { - throw new BadRequestException(validationError); + throw new AppException(ErrorCode.WORKING_HOURS_INVALID, HttpStatus.BAD_REQUEST); } + const timeZone = this.requireTimeZone(dto.timeZone); const normalizedBlocks = this.normalizeBlocks(dto.blocks); await this.assertNoConflictingAppointments( organizationId, membership.userId, normalizedBlocks, + timeZone, ); await this.prisma.$transaction(async (tx) => { @@ -207,10 +206,18 @@ export class StaffWorkingHoursService { })); } + private requireTimeZone(timeZone: string | undefined): string { + if (!timeZone || !isValidIanaTimeZone(timeZone)) { + throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST); + } + return timeZone; + } + private async assertNoConflictingAppointments( organizationId: string, providerUserId: string, blocks: WorkingHoursBlockInput[], + timeZone: string, ) { const now = new Date(); const appointments = await this.prisma.appointment.findMany({ @@ -219,47 +226,30 @@ export class StaffWorkingHoursService { providerUserId, endAt: { gt: now }, }, - include: { - patient: { select: { firstName: true, lastName: true } }, - }, + select: { startAt: true, endAt: true }, orderBy: { startAt: 'asc' }, }); const conflicts = appointments.filter((appointment) => { const startAt = new Date(appointment.startAt); const endAt = new Date(appointment.endAt); - const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); + const dayOfWeek = localDayOfWeekMondayZero( + zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday, + ); const dayBlocks = blocksForDay(blocks, dayOfWeek); if (dayBlocks.length === 0) { return true; } - return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks); + return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone); }); if (conflicts.length === 0) { return; } - const examples = conflicts.slice(0, 3).map((appointment) => { - const startAt = new Date(appointment.startAt); - const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`; - const when = startAt.toLocaleString(undefined, { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - return `${patientName} (${when})`; - }); - - const extra = - conflicts.length > examples.length - ? ` and ${conflicts.length - examples.length} more` - : ''; - - throw new BadRequestException( - `Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`, + throw new AppException( + ErrorCode.WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS, + HttpStatus.CONFLICT, ); } @@ -269,10 +259,10 @@ export class StaffWorkingHoursService { select: { id: true, isOwner: true, userId: true }, }); if (!membership) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (membership.isOwner) { - throw new BadRequestException('Working hours cannot be set for the organization owner'); + throw new AppException(ErrorCode.WORKING_HOURS_OWNER_NOT_ALLOWED, HttpStatus.BAD_REQUEST); } return membership; } @@ -286,7 +276,7 @@ export class StaffWorkingHoursService { }, }); if (!membership) { - throw new ForbiddenException('Only organization owners can manage their working hours'); + throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN); } return membership; } @@ -297,23 +287,21 @@ export class StaffWorkingHoursService { organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null }; }) { if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) { - throw new ForbiddenException( - 'Enable treatment participation before setting working hours', - ); + throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN); } } private async assertCanViewStaff(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canViewStaff(actor)) { - throw new ForbiddenException('You do not have access to staff management'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN); } } private async assertCanEditStaff(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot manage staff working hours'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } } diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index f7bb2e4..09e4399 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -1,10 +1,8 @@ import { - BadRequestException, - ConflictException, - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { Prisma, UserNotificationType } from '@prisma/client'; @@ -28,7 +26,7 @@ export class StaffService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -36,7 +34,7 @@ export class StaffService { async list(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canViewStaff(actor)) { - throw new ForbiddenException('You do not have access to staff management'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN); } const org = await this.prisma.organization.findUnique({ @@ -44,7 +42,7 @@ export class StaffService { include: { plan: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } const [members, seatsUsed] = await Promise.all([ @@ -100,7 +98,7 @@ export class StaffService { async invite(userId: string, organizationId: string, dto: InviteStaffDto) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot invite or manage staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const email = dto.email.trim().toLowerCase(); @@ -114,7 +112,7 @@ export class StaffService { if (permissionRows.length !== normalizedPerms.length) { const ok = new Set(permissionRows.map((p) => p.name)); const missing = normalizedPerms.filter((n) => !ok.has(n)); - throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); + throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST); } const plainToken = this.generateInviteToken(); @@ -126,13 +124,11 @@ export class StaffService { include: { plan: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!org.plan) { - throw new BadRequestException( - 'This organization has no active subscription. Please choose a plan before inviting staff.', - ); + throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST); } const maxUsers = org.plan.maxUsers; @@ -143,9 +139,7 @@ export class StaffService { }, }); if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { - throw new BadRequestException( - `Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`, - ); + throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST); } const existingUser = await tx.user.findUnique({ where: { email } }); @@ -153,7 +147,7 @@ export class StaffService { if (existingUser) { if (existingUser.id === org.ownerId) { - throw new BadRequestException('Organization owner is already a member'); + throw new AppException(ErrorCode.STAFF_INVITE_OWNER_EMAIL, HttpStatus.BAD_REQUEST); } const dup = await tx.membership.findUnique({ where: { @@ -164,7 +158,7 @@ export class StaffService { }, }); if (dup) { - throw new ConflictException('This user is already a member of this organization'); + throw new AppException(ErrorCode.STAFF_ALREADY_MEMBER, HttpStatus.CONFLICT); } targetUserId = existingUser.id; } else { @@ -250,7 +244,7 @@ export class StaffService { async getInvitationLink(userId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot invite or manage staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const membership = await this.prisma.membership.findFirst({ @@ -262,24 +256,24 @@ export class StaffService { }); if (!membership) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (membership.isOwner) { - throw new BadRequestException('Owner does not use an invitation link'); + throw new AppException(ErrorCode.STAFF_OWNER_NO_INVITE_LINK, HttpStatus.BAD_REQUEST); } if (membership.isActive) { - throw new BadRequestException('This member has already accepted their invitation'); + throw new AppException(ErrorCode.STAFF_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST); } const invitation = membership.invitations[0]; if (!invitation) { - throw new BadRequestException('No invitation found for this member'); + throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST); } if (invitation.acceptedAt) { - throw new BadRequestException('This invitation has already been accepted'); + throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST); } if (invitation.revokedAt) { - throw new BadRequestException('This invitation is no longer valid'); + throw new AppException(ErrorCode.STAFF_INVITE_INVALID, HttpStatus.BAD_REQUEST); } const plainToken = this.generateInviteToken(); @@ -324,7 +318,7 @@ export class StaffService { const invitation = await this.findValidInvitation(dto.token); if (invitation.acceptedAt) { - throw new BadRequestException('This invitation has already been accepted'); + throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST); } const passwordHash = await bcrypt.hash(dto.password, 10); @@ -367,7 +361,7 @@ export class StaffService { ) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot edit staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const target = await this.prisma.membership.findFirst({ @@ -379,10 +373,10 @@ export class StaffService { }); if (!target) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (target.isOwner) { - throw new ForbiddenException('Owner membership cannot be edited here'); + throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN); } if (dto.name !== undefined) { @@ -402,7 +396,7 @@ export class StaffService { if (permissionRows.length !== normalizedPerms.length) { const ok = new Set(permissionRows.map((p) => p.name)); const missing = normalizedPerms.filter((n) => !ok.has(n)); - throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`); + throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST); } await this.prisma.$transaction([ @@ -426,7 +420,7 @@ export class StaffService { async enableMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot manage staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const target = await this.prisma.membership.findFirst({ @@ -437,20 +431,18 @@ export class StaffService { }); if (!target) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (target.isOwner) { - throw new ForbiddenException('Cannot enable the organization owner'); + throw new AppException(ErrorCode.STAFF_CANNOT_ENABLE_OWNER, HttpStatus.FORBIDDEN); } if (target.isActive) { - throw new BadRequestException('This member is already active'); + throw new AppException(ErrorCode.STAFF_ALREADY_ACTIVE, HttpStatus.BAD_REQUEST); } const invitation = target.invitations[0]; if (invitation && !invitation.acceptedAt) { - throw new BadRequestException( - 'This member has not completed their invitation yet. Share the invite link instead.', - ); + throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST); } await this.prisma.$transaction(async (tx) => { @@ -470,7 +462,7 @@ export class StaffService { async disableMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot manage staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const target = await this.prisma.membership.findFirst({ @@ -478,16 +470,16 @@ export class StaffService { }); if (!target) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (target.isOwner) { - throw new ForbiddenException('Cannot disable the organization owner'); + throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_OWNER, HttpStatus.FORBIDDEN); } if (actorUserId === target.userId) { - throw new BadRequestException('You cannot disable your own access'); + throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_SELF, HttpStatus.BAD_REQUEST); } if (!target.isActive) { - throw new BadRequestException('This member is already disabled or pending activation'); + throw new AppException(ErrorCode.STAFF_ALREADY_DISABLED, HttpStatus.BAD_REQUEST); } await this.prisma.membership.update({ @@ -508,7 +500,7 @@ export class StaffService { async removeMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { - throw new ForbiddenException('You cannot remove staff'); + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); } const target = await this.prisma.membership.findFirst({ @@ -516,10 +508,10 @@ export class StaffService { }); if (!target) { - throw new NotFoundException('Member not found'); + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); } if (target.isOwner) { - throw new ForbiddenException('Cannot remove the organization owner'); + throw new AppException(ErrorCode.STAFF_CANNOT_REMOVE_OWNER, HttpStatus.FORBIDDEN); } await this.prisma.membership.delete({ where: { id: membershipId } }); @@ -536,12 +528,10 @@ export class StaffService { include: { plan: true }, }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!org.plan) { - throw new BadRequestException( - 'This organization has no active subscription. Please choose a plan before adding staff.', - ); + throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST); } const maxUsers = org.plan.maxUsers; @@ -552,9 +542,7 @@ export class StaffService { }, }); if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { - throw new BadRequestException( - `Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`, - ); + throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST); } } @@ -615,13 +603,13 @@ export class StaffService { }); if (!invitation) { - throw new NotFoundException('Invitation not found'); + throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.NOT_FOUND); } if (invitation.revokedAt) { - throw new BadRequestException('Invitation has been revoked'); + throw new AppException(ErrorCode.STAFF_INVITE_REVOKED, HttpStatus.BAD_REQUEST); } if (invitation.expiresAt.getTime() <= Date.now()) { - throw new BadRequestException('Invitation has expired'); + throw new AppException(ErrorCode.STAFF_INVITE_EXPIRED, HttpStatus.BAD_REQUEST); } return invitation; } diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 531d0ff..07ad335 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -11,7 +11,7 @@ import { Min, } from 'class-validator'; import { Transform } from 'class-transformer'; -import { LabTaskStatus } from '@prisma/client'; +import { LabTaskStatus, LabCaseOrigin } from '@prisma/client'; const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; @@ -105,6 +105,11 @@ export class ListLabTasksDto { @IsString() prosthesisTypeCode?: string; + /** CLINIC_DISPATCH = received from clinic; LAB_INTERNAL = generated in the lab. */ + @IsOptional() + @IsEnum(LabCaseOrigin) + origin?: LabCaseOrigin; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; @@ -194,6 +199,10 @@ export class LocateTaskPageDto { @IsString() prosthesisTypeCode?: string; + @IsOptional() + @IsEnum(LabCaseOrigin) + origin?: LabCaseOrigin; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 9a84300..efd45f1 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -1,10 +1,9 @@ import { - BadRequestException, - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; +import { AppException, ErrorCode } from '../../common/errors'; +import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -24,6 +23,7 @@ const taskListInclude = { labCase: { include: { tasks: { select: { status: true } }, + partnerClinic: { select: { id: true, name: true } }, treatment: { include: { organization: { select: { id: true, name: true } }, @@ -48,7 +48,7 @@ export class TasksService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -111,14 +111,13 @@ export class TasksService { const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId } }, + ...this.visibleStartedToLabWhere(organizationId), }, select: { id: true }, }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return this.listTasksForLabCaseId(labCaseId, localeInput); @@ -130,6 +129,7 @@ export class TasksService { include: taskListInclude, orderBy: [ { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -165,18 +165,16 @@ export class TasksService { const target = await this.prisma.labCaseTask.findFirst({ where: { id: query.taskId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, include: { - labCase: { select: { sentAt: true } }, + labCase: { select: { sentAt: true, startedAt: true } }, }, }); - if (!target?.labCase.sentAt) { - throw new NotFoundException('Task not found'); + const effectiveAt = target?.labCase.sentAt ?? target?.labCase.startedAt; + if (!target || !effectiveAt) { + throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND); } const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery); @@ -195,9 +193,9 @@ export class TasksService { where, listQuery, { - sentAt: target.labCase.sentAt, + sentAt: effectiveAt, labCaseId: target.labCaseId, - treatmentDetailId: target.treatmentDetailId, + sourceKey: target.sourceKey, prosthesisTypeCode: target.prosthesisTypeCode, stepOrder: target.stepOrder, id: target.id, @@ -226,20 +224,17 @@ export class TasksService { const task = await this.prisma.labCaseTask.findFirst({ where: { id: taskId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, include: taskListInclude, }); if (!task) { - throw new NotFoundException('Task not found'); + throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND); } if (task.assigneeUserId && task.assigneeUserId !== actorUserId) { - throw new ForbiddenException('This task is assigned to another staff member'); + throw new AppException(ErrorCode.TASK_ASSIGNED_TO_OTHER, HttpStatus.FORBIDDEN); } const updated = await this.prisma.$transaction(async (tx) => { @@ -367,11 +362,9 @@ export class TasksService { await this.assertCanReadTasks(actorUserId, labOrganizationId); const rows = await this.prisma.labCase.findMany({ - where: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + where: this.visibleStartedToLabWhere(labOrganizationId), select: { + partnerClinic: { select: { id: true, name: true } }, treatment: { select: { organization: { select: { id: true, name: true } }, @@ -382,7 +375,12 @@ export class TasksService { const clinicsById = new Map(); for (const row of rows) { - clinicsById.set(row.treatment.organization.id, row.treatment.organization); + if (row.treatment?.organization) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + } + if (row.partnerClinic) { + clinicsById.set(row.partnerClinic.id, row.partnerClinic); + } } const locale = normalizeCatalogLocale(localeInput); @@ -419,14 +417,14 @@ export class TasksService { if (query.sentFrom) { const from = new Date(query.sentFrom); if (Number.isNaN(from.getTime())) { - throw new BadRequestException('Invalid sentFrom date'); + throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST); } sentAtFilter.gte = from; } if (query.sentTo) { const to = new Date(query.sentTo); if (Number.isNaN(to.getTime())) { - throw new BadRequestException('Invalid sentTo date'); + throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST); } to.setHours(23, 59, 59, 999); sentAtFilter.lte = to; @@ -443,12 +441,34 @@ export class TasksService { } const labCaseScope: Prisma.LabCaseWhereInput = { - sentAt: sentAtFilter, - sends: { some: { organizationId: labOrganizationId } }, - ...(query.clinicOrganizationId - ? { treatment: { organizationId: query.clinicOrganizationId } } - : {}), - ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + AND: [ + this.visibleStartedToLabWhere(labOrganizationId), + ...(query.sentFrom || query.sentTo + ? [ + { + OR: [ + { sentAt: sentAtFilter }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + startedAt: sentAtFilter, + }, + ], + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.clinicOrganizationId + ? [ + { + OR: [ + { treatment: { organizationId: query.clinicOrganizationId } }, + { partnerClinicOrganizationId: query.clinicOrganizationId }, + ], + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.origin ? [{ origin: query.origin } satisfies Prisma.LabCaseWhereInput] : []), + ...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []), + ], }; const base: Prisma.LabCaseTaskWhereInput = { @@ -476,7 +496,7 @@ export class TasksService { } const completedGroups = await this.prisma.labCaseTask.groupBy({ - by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'], + by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'], where: { workflowStepCode: stepCompleted, status: LabTaskStatus.COMPLETED, @@ -494,7 +514,7 @@ export class TasksService { { OR: completedGroups.map((group) => ({ labCaseId: group.labCaseId, - treatmentDetailId: group.treatmentDetailId, + sourceKey: group.sourceKey, prosthesisTypeCode: group.prosthesisTypeCode, })), }, @@ -518,6 +538,7 @@ export class TasksService { overdue: query.overdue, unassignedOnly: query.unassignedOnly, prosthesisTypeCode: query.prosthesisTypeCode, + origin: query.origin, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, @@ -530,7 +551,7 @@ export class TasksService { target: { sentAt: Date; labCaseId: string; - treatmentDetailId: string; + sourceKey: string; prosthesisTypeCode: string; stepOrder: number; id: string; @@ -540,11 +561,15 @@ export class TasksService { const dir = query.sortDir ?? 'desc'; if (sortBy !== 'date') { - throw new BadRequestException('Task page location is only supported for date sort'); + throw new AppException(ErrorCode.TASK_PAGE_DATE_SORT_ONLY, HttpStatus.BAD_REQUEST); } const sentAt = target.sentAt; - const sameSentAt = { labCase: { sentAt } }; + const sameSentAt = { + labCase: { + OR: [{ sentAt }, { AND: [{ sentAt: null }, { startedAt: sentAt }] }], + }, + }; const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [ { AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }], @@ -553,14 +578,14 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: { lt: target.treatmentDetailId } }, + { sourceKey: { lt: target.sourceKey } }, ], }, { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: { lt: target.prosthesisTypeCode } }, ], }, @@ -568,7 +593,7 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: { lt: target.stepOrder } }, ], @@ -577,7 +602,7 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: target.stepOrder }, { id: { lt: target.id } }, @@ -587,8 +612,16 @@ export class TasksService { const sentAtBefore: Prisma.LabCaseTaskWhereInput = dir === 'desc' - ? { labCase: { sentAt: { gt: sentAt } } } - : { labCase: { sentAt: { lt: sentAt } } }; + ? { + labCase: { + OR: [{ sentAt: { gt: sentAt } }, { startedAt: { gt: sentAt } }], + }, + } + : { + labCase: { + OR: [{ sentAt: { lt: sentAt } }, { startedAt: { lt: sentAt } }], + }, + }; return this.prisma.labCaseTask.count({ where: { @@ -597,21 +630,49 @@ export class TasksService { }); } - private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { - const orConditions: Prisma.PatientWhereInput[] = [ - { firstName: { contains: q, mode: 'insensitive' } }, - { lastName: { contains: q, mode: 'insensitive' } }, + private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + startedAt: { not: null }, + }, + ], + }; + } + + private buildTaskSearchWhere(q: string): Prisma.LabCaseWhereInput { + const orConditions: Prisma.LabCaseWhereInput[] = [ + { + treatment: { + patient: { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + treatment: { + organization: { name: { contains: q, mode: 'insensitive' } }, + }, + }, + { referringClinicName: { contains: q, mode: 'insensitive' } }, + { referringDentistName: { contains: q, mode: 'insensitive' } }, + { patientDisplayName: { contains: q, mode: 'insensitive' } }, + { patientDisplayMobile: { contains: q, mode: 'insensitive' } }, ]; const normalized = normalizeMobile(q); if (normalized) { - orConditions.push({ mobile: normalized }); + orConditions.push({ treatment: { patient: { mobile: normalized } } }); } - return { - OR: [ - { patient: { OR: orConditions } }, - { organization: { name: { contains: q, mode: 'insensitive' } } }, - ], - }; + return { OR: orConditions }; } private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { @@ -629,6 +690,7 @@ export class TasksService { break; case 'clinic': orderBy = [ + { labCase: { referringClinicName: dir } }, { labCase: { treatment: { organization: { name: dir } } } }, { createdAt: 'desc' }, ...stepTiebreakers, @@ -636,6 +698,7 @@ export class TasksService { break; case 'patient': orderBy = [ + { labCase: { patientDisplayName: dir } }, { labCase: { treatment: { patient: { lastName: dir } } } }, { labCase: { treatment: { patient: { firstName: dir } } } }, ...stepTiebreakers, @@ -662,9 +725,10 @@ export class TasksService { case 'dueDate': orderBy = [ { labCase: { dueDate: dir } }, + { labCase: { startedAt: 'desc' } }, { labCase: { sentAt: 'desc' } }, { labCaseId: 'asc' }, - { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -673,9 +737,10 @@ export class TasksService { case 'date': default: orderBy = [ + { labCase: { startedAt: dir } }, { labCase: { sentAt: dir } }, { labCaseId: 'asc' }, - { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -694,10 +759,24 @@ export class TasksService { task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, prosthesisLabels: Map, ) { + const clinic = + task.labCase.treatment?.organization ?? + task.labCase.partnerClinic ?? { + id: '', + name: task.labCase.referringClinicName?.trim() || '', + }; + const displayName = task.labCase.patientDisplayName?.trim() || ''; + const [firstName, ...rest] = displayName.split(/\s+/); + const patient = task.labCase.treatment?.patient ?? { + id: '', + firstName: firstName || displayName || clinic.name || '—', + lastName: rest.join(' '), + }; + return { id: task.id, labCaseId: task.labCaseId, - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey, teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -719,20 +798,17 @@ export class TasksService { ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, createdAt: task.createdAt.toISOString(), - caseSentAt: task.labCase.sentAt?.toISOString() ?? null, - clinic: task.labCase.treatment.organization, - patient: { - id: task.labCase.treatment.patient.id, - firstName: task.labCase.treatment.patient.firstName, - lastName: task.labCase.treatment.patient.lastName, - }, + caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null, + origin: task.labCase.origin, + clinic, + patient, }; } private async assertCanReadTasks(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if ( hasEffectivePermission(m, 'TAB_TASKS_READ') || @@ -740,18 +816,18 @@ export class TasksService { ) { return; } - throw new ForbiddenException('You do not have access to tasks'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN); } private async assertCanEditTasks(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) { return; } - throw new ForbiddenException('You cannot update tasks'); + throw new AppException(ErrorCode.PERMISSION_EDIT_TASKS, HttpStatus.FORBIDDEN); } private async getMembership(userId: string, organizationId: string) { diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 1410061..7ed5187 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -1,8 +1,8 @@ import { - BadRequestException, - ForbiddenException, + HttpStatus, Injectable, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { @@ -107,7 +107,7 @@ export class TodayService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -428,10 +428,10 @@ export class TodayService { const from = new Date(query.from); const to = new Date(query.to); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { - throw new BadRequestException('Invalid date range'); + throw new AppException(ErrorCode.TODAY_INVALID_RANGE, HttpStatus.BAD_REQUEST); } if (to <= from) { - throw new BadRequestException('Range "to" must be after "from"'); + throw new AppException(ErrorCode.TODAY_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST); } return { from, to }; } @@ -1243,6 +1243,7 @@ export class TodayService { }, select: { destinationOrganizationId: true, + partnerClinicOrganizationId: true, tasks: { select: { status: true } }, treatment: { select: { organizationId: true } }, }, @@ -1254,7 +1255,7 @@ export class TodayService { const partnerId = orgType === 'CLINIC' ? labCase.destinationOrganizationId - : labCase.treatment.organizationId; + : labCase.treatment?.organizationId ?? labCase.partnerClinicOrganizationId; if (!partnerId) continue; const isCompleted = @@ -1324,7 +1325,7 @@ export class TodayService { }); if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } return membership; @@ -1345,7 +1346,7 @@ export class TodayService { private assertCanViewToday(isOwner: boolean, permissionNames: string[]) { if (isOwner) return; if (!permissionNames.includes('TAB_TODAY_READ')) { - throw new ForbiddenException('You do not have access to Today'); + throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); } } diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts index 418bfb4..2b8a9e7 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -1,5 +1,5 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { BadRequestException } from '@nestjs/common'; +import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { CatalogEntityKind } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { @@ -101,7 +101,7 @@ export class TreatmentCatalogService implements OnModuleInit { assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry { const entry = this.getByCode(code); if (!entry) { - throw new BadRequestException(`Unknown treatment type: ${code}`); + throw new AppException(ErrorCode.CATALOG_UNKNOWN_TREATMENT_TYPE, HttpStatus.BAD_REQUEST); } return entry; } @@ -109,9 +109,7 @@ export class TreatmentCatalogService implements OnModuleInit { assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry { const entry = this.assertKnownTreatmentType(code); if (!entry.labDependent) { - throw new BadRequestException( - `Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`, - ); + throw new AppException(ErrorCode.CATALOG_TREATMENT_NOT_LAB_DEPENDENT, HttpStatus.BAD_REQUEST); } return entry; } diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3bb6a24..c67f1b6 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,6 +1,6 @@ import { - ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsIn, IsOptional, @@ -60,7 +60,6 @@ export class SaveTreatmentDetailDto { export class SaveTreatmentDraftDto { @IsArray() - @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => SaveTreatmentDetailDto) details: SaveTreatmentDetailDto[]; @@ -133,3 +132,24 @@ export class ListPatientTreatmentHistoryDto { @IsOptional() limit?: number; } + +export class CreateStandaloneTreatmentDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsBoolean() + walkIn?: boolean; + + @IsDateString() + treatmentAt: string; +} + +export class ListDayTreatmentsDto { + @IsDateString() + from: string; + + @IsDateString() + to: string; +} diff --git a/backend/src/modules/treatments/lab-case-send.validation.spec.ts b/backend/src/modules/treatments/lab-case-send.validation.spec.ts index 3bab06e..3916269 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.spec.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.spec.ts @@ -1,3 +1,4 @@ +import { AppException } from '../../common/errors'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; describe('assertCompleteToothProsthesisMap', () => { @@ -47,6 +48,6 @@ describe('assertCompleteToothProsthesisMap', () => { { treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' }, ], }), - ).toThrow('missing tooth 15'); + ).toThrow(AppException); }); }); diff --git a/backend/src/modules/treatments/lab-case-send.validation.ts b/backend/src/modules/treatments/lab-case-send.validation.ts index 4a28382..c923713 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.ts @@ -1,4 +1,5 @@ -import { BadRequestException } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { normalizeTeeth } from './treatment.utils'; export type LabCaseProsthesisLink = { @@ -28,9 +29,7 @@ export function assertCompleteToothProsthesisMap(labCase: { for (const tooth of teeth) { const key = `${link.treatmentDetailId}:${tooth}`; if (!prosthesisByKey.has(key)) { - throw new BadRequestException( - `Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`, - ); + throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST); } } } diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 2b6f313..abd6f8c 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseIntPipe, @@ -24,6 +25,8 @@ import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, UpdateLabCaseDueDateDto, + CreateStandaloneTreatmentDto, + ListDayTreatmentsDto, } from './dto/treatment.dto'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; @@ -83,6 +86,31 @@ export class TreatmentsController { ); } + @Get('day') + @ApiOperation({ summary: 'Standalone (no appointment) treatments for a local day range' }) + listDayStandalone( + @Query() query: ListDayTreatmentsDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listDayStandalone( + query.from, + query.to, + organizationId, + req.user.id, + ); + } + + @Post() + @ApiOperation({ summary: 'Create a treatment without an appointment (TAB_TREATMENT_EDIT)' }) + createStandalone( + @Body() dto: CreateStandaloneTreatmentDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.createStandalone(dto, organizationId, req.user.id); + } + @Get('appointments/:appointmentId/draft') @ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' }) getDraft( @@ -268,4 +296,84 @@ export class TreatmentsController { dto, ); } + + @Get(':treatmentId/draft') + @ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' }) + getDraftByTreatment( + @Param('treatmentId') treatmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.getDraftForTreatment( + treatmentId, + organizationId, + req.user.id, + ); + } + + @Put(':treatmentId/draft') + @ApiOperation({ summary: 'Save draft treatment details by treatment id (TAB_TREATMENT_EDIT)' }) + saveDraftByTreatment( + @Param('treatmentId') treatmentId: string, + @Body() dto: SaveTreatmentDraftDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveDraftForTreatment( + treatmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Put(':treatmentId/lab-cases') + @ApiOperation({ summary: 'Save lab case groupings by treatment id (TAB_TREATMENT_EDIT)' }) + saveLabCasesByTreatment( + @Param('treatmentId') treatmentId: string, + @Body() dto: SaveTreatmentLabCasesDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveLabCasesForTreatment( + treatmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post(':treatmentId/details/:detailClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a standalone treatment detail' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadDetailAttachmentsByTreatment( + @Param('treatmentId') treatmentId: string, + @Param('detailClientKey') detailClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadDetailAttachmentsForTreatment( + treatmentId, + detailClientKey, + files, + organizationId, + req.user.id, + ); + } + + @Delete(':treatmentId') + @ApiOperation({ summary: 'Delete an empty standalone treatment (no details)' }) + deleteStandalone( + @Param('treatmentId') treatmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.deleteStandalone(treatmentId, organizationId, req.user.id); + } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 4572ea4..f370657 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -1,9 +1,6 @@ import { - BadRequestException, - ForbiddenException, HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; @@ -19,6 +16,7 @@ import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, UpdateLabCaseDueDateDto, + CreateStandaloneTreatmentDto, } from './dto/treatment.dto'; import { isLabCaseFullyCompleted, @@ -39,6 +37,7 @@ import { isActorTreatmentProvider, treatmentProviderScopeWhere, } from '../../common/treatment-provider-scope'; +import { ensureWalkInPatient } from '../../common/walk-in-patient'; const sentLabCaseInclude = { treatment: { @@ -70,6 +69,9 @@ const sentLabCaseInclude = { type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>; const treatmentInclude = { + patient: { + select: { id: true, firstName: true, lastName: true, isWalkIn: true }, + }, details: { orderBy: [{ sortOrder: 'asc' as const }], include: { @@ -136,7 +138,7 @@ export class TreatmentsService { getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } return user.organizationId; } @@ -195,6 +197,108 @@ export class TreatmentsService { return { success: true, data: items.map((t) => this.mapTreatment(t)) }; } + async createStandalone( + dto: CreateStandaloneTreatmentDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + + const walkIn = Boolean(dto.walkIn); + if (!walkIn && !dto.patientId) { + throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST); + } + + const treatmentAt = new Date(dto.treatmentAt); + if (Number.isNaN(treatmentAt.getTime())) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + + let patientId: string; + if (walkIn) { + 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); + } + patientId = dto.patientId!; + } + + const treatment = await this.prisma.treatment.create({ + data: { + organizationId, + patientId, + appointmentId: null, + providerUserId: actorUserId, + title: generateTreatmentTitle([]), + treatmentAt, + }, + include: treatmentInclude, + }); + + return { success: true, data: this.mapTreatment(treatment) }; + } + + async deleteStandalone( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const treatment = await this.ensureTreatmentProvider( + treatmentId, + organizationId, + actorUserId, + ); + + if (treatment.appointmentId) { + throw new AppException(ErrorCode.TREATMENT_NOT_STANDALONE, HttpStatus.CONFLICT); + } + + const detailCount = await this.prisma.treatmentDetail.count({ + where: { treatmentId }, + }); + if (detailCount > 0) { + throw new AppException(ErrorCode.TREATMENT_HAS_DETAILS, HttpStatus.CONFLICT); + } + + await this.prisma.treatment.delete({ where: { id: treatmentId } }); + return { success: true }; + } + + async listDayStandalone( + fromIso: string, + toIso: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + const from = new Date(fromIso); + const to = new Date(toIso); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + + const items = await this.prisma.treatment.findMany({ + where: { + organizationId, + appointmentId: null, + treatmentAt: { gte: from, lt: to }, + ...treatmentProviderScopeWhere(actorUserId), + }, + include: treatmentInclude, + orderBy: [{ treatmentAt: 'asc' }, { createdAt: 'asc' }], + }); + + return { success: true, data: items.map((t) => this.mapTreatment(t)) }; + } + async listPatientLabCases( patientId: string, organizationId: string, @@ -325,16 +429,20 @@ export class TreatmentsService { })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); - const patient = lc.treatment.patient; + const treatment = lc.treatment; + if (!treatment) { + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + const patient = treatment.patient; return { labCaseId: lc.id, patientId: patient.id, patientFirstName: patient.firstName, patientLastName: patient.lastName, - treatmentId: lc.treatment.id, - appointmentId: lc.treatment.appointmentId, - treatmentAt: lc.treatment.treatmentAt.toISOString(), + treatmentId: treatment.id, + appointmentId: treatment.appointmentId, + treatmentAt: treatment.treatmentAt.toISOString(), detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '', teeth: detailTeeth, prosthesisGroups, @@ -388,6 +496,15 @@ export class TreatmentsService { return { success: true, data: treatment ? this.mapTreatment(treatment) : null }; } + async getDraftForTreatment( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + return { success: true, data: this.mapTreatment(treatment) }; + } + async saveDraftForAppointment( appointmentId: string, dto: SaveTreatmentDraftDto, @@ -401,11 +518,78 @@ export class TreatmentsService { actorUserId, ); - for (const d of dto.details) { + const existing = await this.prisma.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId }, + select: { id: true }, + }); + + const treatmentId = existing + ? existing.id + : ( + await this.prisma.treatment.create({ + data: { + organizationId, + patientId: appointment.patientId, + appointmentId: appointment.id, + providerUserId: actorUserId, + title: generateTreatmentTitle([]), + treatmentAt: appointment.startAt, + }, + select: { id: true }, + }) + ).id; + + const saved = await this.persistDraftDetails({ + treatmentId, + organizationId, + actorUserId, + dto, + pendingAppointmentId: appointment.id, + treatmentAt: appointment.startAt, + patientId: appointment.patientId, + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + async saveDraftForTreatment( + treatmentId: string, + dto: SaveTreatmentDraftDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + const saved = await this.persistDraftDetails({ + treatmentId, + organizationId, + actorUserId, + dto, + pendingAppointmentId: existing.appointmentId, + pendingTreatmentId: existing.appointmentId ? null : existing.id, + treatmentAt: existing.appointmentId ? existing.treatmentAt : existing.treatmentAt, + patientId: existing.patientId, + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + private async persistDraftDetails(args: { + treatmentId: string; + organizationId: string; + actorUserId: string; + dto: SaveTreatmentDraftDto; + pendingAppointmentId: string | null; + pendingTreatmentId?: string | null; + treatmentAt: Date; + patientId: string; + }) { + for (const d of args.dto.details) { this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } - const normalizedDetails = dto.details.map((d, index) => { + const normalizedDetails = args.dto.details.map((d, index) => { const teeth = normalizeTeeth(d.teeth); const toothSelectionGroups = normalizeToothSelectionGroups( d.toothSelectionGroups, @@ -425,41 +609,23 @@ export class TreatmentsService { normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); - const treatment = await this.prisma.$transaction(async (tx) => { - const existing = await tx.treatment.findFirst({ - where: { appointmentId: appointment.id, organizationId }, - select: { id: true }, + return this.prisma.$transaction(async (tx) => { + const saved = await tx.treatment.update({ + where: { id: args.treatmentId }, + data: { + title, + treatmentAt: args.treatmentAt, + patientId: args.patientId, + providerUserId: args.actorUserId, + }, }); - const saved = existing - ? await tx.treatment.update({ - where: { id: existing.id }, - data: { - title, - treatmentAt: appointment.startAt, - patientId: appointment.patientId, - providerUserId: actorUserId, - }, - }) - : await tx.treatment.create({ - data: { - organizationId, - patientId: appointment.patientId, - appointmentId: appointment.id, - providerUserId: actorUserId, - title, - treatmentAt: appointment.startAt, - }, - }); - const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; - const existingDetails = existing - ? await tx.treatmentDetail.findMany({ - where: { treatmentId: saved.id }, - select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, - }) - : []; + const existingDetails = await tx.treatmentDetail.findMany({ + where: { treatmentId: saved.id }, + select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, + }); const lockedDetailIds = new Set( existingDetails @@ -507,11 +673,14 @@ export class TreatmentsService { }); const allowedAttachmentIds = new Set(d.attachmentIds); + const pendingWhere: Prisma.TreatmentDetailAttachmentWhereInput = { + detailClientKey: d.clientId, + ...(args.pendingAppointmentId + ? { appointmentId: args.pendingAppointmentId } + : { treatmentId: args.pendingTreatmentId ?? saved.id }), + }; const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ - where: { - appointmentId: appointment.id, - detailClientKey: d.clientId, - }, + where: pendingWhere, }); for (const attachment of pendingAttachments) { @@ -520,7 +689,12 @@ export class TreatmentsService { } else { await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, - data: { detailId: row.id, appointmentId: null, detailClientKey: null }, + data: { + detailId: row.id, + appointmentId: null, + treatmentId: null, + detailClientKey: null, + }, }); } } @@ -538,8 +712,6 @@ export class TreatmentsService { include: treatmentInclude, }); }); - - return { success: true, data: this.mapTreatment(treatment) }; } async saveLabCasesForAppointment( @@ -561,13 +733,27 @@ export class TreatmentsService { }); if (!treatment) { - throw new NotFoundException('Save treatment details before creating lab cases'); + throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND); } + return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId); + } + + async saveLabCasesForTreatment( + treatmentId: string, + dto: SaveTreatmentLabCasesDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + const treatment = { id: treatmentId }; + const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId); const uniqueDetailIds = new Set(detailIds); if (uniqueDetailIds.size !== detailIds.length) { - throw new BadRequestException('Each treatment detail can belong to only one lab case'); + throw new AppException(ErrorCode.TREATMENT_DETAIL_ONE_CASE, HttpStatus.BAD_REQUEST); } const details = await this.prisma.treatmentDetail.findMany({ @@ -575,7 +761,7 @@ export class TreatmentsService { select: { id: true, treatmentType: true, teeth: true }, }); if (details.length !== uniqueDetailIds.size) { - throw new BadRequestException('One or more treatment details were not found'); + throw new AppException(ErrorCode.TREATMENT_DETAILS_NOT_FOUND, HttpStatus.BAD_REQUEST); } for (const detail of details) { @@ -587,22 +773,20 @@ export class TreatmentsService { for (const lc of dto.labCases) { if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { - throw new BadRequestException('Destination organization is not an active linked counterpart'); + throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST); } for (const row of lc.toothProsthesis ?? []) { if (lc.treatmentDetailId !== row.treatmentDetailId) { - throw new BadRequestException( - 'Tooth prosthesis must reference the lab case treatment detail', - ); + throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST); } const detail = detailById.get(row.treatmentDetailId); if (!detail) { - throw new BadRequestException('Tooth prosthesis references an unknown treatment detail'); + throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST); } const teeth = normalizeTeeth(detail.teeth); if (!teeth.includes(row.tooth)) { - throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`); + throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST); } this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode); } @@ -641,8 +825,14 @@ export class TreatmentsService { continue; } - const dueDate = - lc.dueDate !== undefined ? parseDueDateInput(lc.dueDate) : undefined; + let dueDate: Date | null | undefined; + if (lc.dueDate !== undefined) { + try { + dueDate = parseDueDateInput(lc.dueDate); + } catch { + throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST); + } + } const row = lc.id ? await tx.labCase.update({ @@ -657,6 +847,7 @@ export class TreatmentsService { : await tx.labCase.create({ data: { treatmentId: treatment.id, + origin: 'CLINIC_DISPATCH', clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, @@ -678,6 +869,8 @@ export class TreatmentsService { data: lc.toothProsthesis.map((tp) => ({ labCaseId: row.id, treatmentDetailId: tp.treatmentDetailId, + sourceKey: tp.treatmentDetailId, + treatmentType: detailById.get(tp.treatmentDetailId)?.treatmentType ?? 'prosthesis', tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, selectionGroupId: tp.selectionGroupId?.trim() || '', @@ -696,9 +889,7 @@ export class TreatmentsService { select: { id: true }, }); if (validAttachments.length !== attachmentIds.length) { - throw new BadRequestException( - 'One or more attachments are invalid for this lab case', - ); + throw new AppException(ErrorCode.TREATMENT_CASE_INVALID_ATTACHMENTS, HttpStatus.BAD_REQUEST); } await tx.labCaseAttachment.createMany({ data: attachmentIds.map((attachmentId) => ({ @@ -749,37 +940,47 @@ export class TreatmentsService { }); if (!labCase) { - throw new NotFoundException('Lab case not found'); + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!labCase.destinationOrganizationId) { - throw new BadRequestException('Lab case has no destination organization'); + throw new AppException(ErrorCode.TREATMENT_CASE_NO_DEST, HttpStatus.BAD_REQUEST); } if (labCase.details.length === 0) { - throw new BadRequestException('Lab case must include a treatment detail'); + throw new AppException(ErrorCode.TREATMENT_CASE_NEEDS_DETAIL, HttpStatus.BAD_REQUEST); } if (labCase.details.length > 1) { - throw new BadRequestException('Lab case can include only one treatment detail'); + throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST); } - assertCompleteToothProsthesisMap(labCase); + if (!labCase.treatment) { + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + + assertCompleteToothProsthesisMap({ + details: labCase.details, + toothProsthesis: labCase.toothProsthesis.filter( + (row): row is typeof row & { treatmentDetailId: string } => + Boolean(row.treatmentDetailId), + ), + }); if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { - throw new ForbiddenException('Only the treatment provider can send this lab case'); + throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN); } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); if (!linkedOrgIds.has(labCase.destinationOrganizationId)) { - throw new BadRequestException('Destination organization is not an active linked counterpart'); + throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST); } const alreadySent = labCase.sends.some( (s) => s.organizationId === labCase.destinationOrganizationId, ); if (alreadySent) { - throw new BadRequestException('Lab case was already sent to the destination organization'); + throw new AppException(ErrorCode.TREATMENT_CASE_ALREADY_SENT, HttpStatus.BAD_REQUEST); } const now = new Date(); @@ -922,18 +1123,18 @@ export class TreatmentsService { }); if (!labCase) { - throw new NotFoundException('Lab case not found'); + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } if (isLabCaseFullyCompleted(labCase.tasks)) { - throw new BadRequestException('Due date cannot be changed after all tasks are completed'); + throw new AppException(ErrorCode.TREATMENT_DUE_DATE_LOCKED, HttpStatus.BAD_REQUEST); } let dueDate: Date | null; try { dueDate = parseDueDateInput(dueDateInput ?? null); } catch { - throw new BadRequestException('Invalid due date'); + throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST); } await tx.labCase.update({ @@ -953,11 +1154,11 @@ export class TreatmentsService { await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId); if (!detailClientKey?.trim()) { - throw new BadRequestException('detailClientKey is required'); + throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); } if (!files?.length) { - throw new BadRequestException('At least one file is required'); + throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); } const existingDetail = await this.prisma.treatmentDetail.findFirst({ @@ -1012,6 +1213,72 @@ export class TreatmentsService { return { success: true, data: created }; } + async uploadDetailAttachmentsForTreatment( + treatmentId: string, + detailClientKey: string, + files: Express.Multer.File[], + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + if (!detailClientKey?.trim()) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); + } + + if (!files?.length) { + throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); + } + + const existingDetail = await this.prisma.treatmentDetail.findFirst({ + where: { + clientKey: detailClientKey, + treatmentId, + }, + select: { + labCaseLink: { + select: { labCase: { select: { sentAt: true } } }, + }, + }, + }); + if (existingDetail?.labCaseLink?.labCase.sentAt) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT); + } + + const orgDir = join(this.uploadRoot, organizationId); + mkdirSync(orgDir, { recursive: true }); + + const created: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }[] = []; + + for (const file of files) { + const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; + const storagePath = join(orgDir, storageName); + const { writeFileSync } = await import('fs'); + writeFileSync(storagePath, file.buffer); + + const attachment = await this.prisma.treatmentDetailAttachment.create({ + data: { + treatmentId, + detailClientKey, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + storagePath, + }, + }); + + created.push(this.mapAttachment(attachment)); + } + + return { success: true, data: created }; + } + async streamAttachmentFile( attachmentId: string, organizationId: string, @@ -1032,6 +1299,7 @@ export class TreatmentsService { }, }, { appointmentId: { not: null } }, + { treatmentId: { not: null } }, ], }, include: { @@ -1040,11 +1308,11 @@ export class TreatmentsService { }); if (!attachment) { - throw new NotFoundException('Attachment not found'); + throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) { - throw new NotFoundException('Attachment not found'); + throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (!attachment.detail && attachment.appointmentId) { @@ -1057,12 +1325,26 @@ export class TreatmentsService { select: { id: true }, }); if (!appointment) { - throw new NotFoundException('Attachment not found'); + throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } + } + + if (!attachment.detail && attachment.treatmentId) { + const treatment = await this.prisma.treatment.findFirst({ + where: { + id: attachment.treatmentId, + organizationId, + providerUserId: actorUserId, + }, + select: { id: true }, + }); + if (!treatment) { + throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } if (!existsSync(attachment.storagePath)) { - throw new NotFoundException('File is no longer available'); + throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND); } return { @@ -1076,8 +1358,15 @@ export class TreatmentsService { id: string; patientId: string; appointmentId: string | null; + providerUserId?: string; title: string; treatmentAt: Date; + patient?: { + id: string; + firstName: string; + lastName: string; + isWalkIn: boolean; + }; details: Array<{ id: string; clientKey: string | null; @@ -1128,8 +1417,17 @@ export class TreatmentsService { id: treatment.id, patientId: treatment.patientId, appointmentId: treatment.appointmentId, + providerUserId: treatment.providerUserId ?? null, title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), + patient: treatment.patient + ? { + id: treatment.patient.id, + firstName: treatment.patient.firstName, + lastName: treatment.patient.lastName, + isWalkIn: treatment.patient.isWalkIn, + } + : null, details: treatment.details.map((d) => this.mapDetail(d)), labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, @@ -1210,7 +1508,7 @@ export class TreatmentsService { organization?: { id: string; name: string }; }>; toothProsthesis?: Array<{ - treatmentDetailId: string; + treatmentDetailId: string | null; tooth: string; prosthesisTypeCode: string; selectionGroupId?: string; @@ -1316,10 +1614,28 @@ export class TreatmentsService { select: { id: true }, }); if (!patient) { - throw new NotFoundException('Patient not found'); + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } + private async ensureTreatmentProvider( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + const treatment = await this.prisma.treatment.findFirst({ + where: { id: treatmentId, organizationId }, + include: treatmentInclude, + }); + if (!treatment) { + throw new AppException(ErrorCode.TREATMENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (!isActorTreatmentProvider(treatment, actorUserId)) { + throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN); + } + return treatment; + } + private async ensureAppointmentProvider( appointmentId: string, organizationId: string, @@ -1336,11 +1652,11 @@ export class TreatmentsService { }); if (!appointment) { - throw new NotFoundException('Appointment not found'); + throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND); } if (appointment.providerUserId !== actorUserId) { - throw new ForbiddenException('You are not the provider for this appointment'); + throw new AppException(ErrorCode.APPOINTMENT_NOT_PROVIDER, HttpStatus.FORBIDDEN); } return appointment; @@ -1349,7 +1665,7 @@ export class TreatmentsService { private async assertCanReadTreatment(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if ( hasEffectivePermission(m, 'TAB_TREATMENT_READ') || @@ -1357,18 +1673,18 @@ export class TreatmentsService { ) { return; } - throw new ForbiddenException('You do not have access to treatments'); + throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN); } private async assertCanEditTreatment(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { - throw new ForbiddenException('You are not a member of this organization'); + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); } if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) { return; } - throw new ForbiddenException('You cannot edit treatments'); + throw new AppException(ErrorCode.PERMISSION_EDIT_TREATMENTS, HttpStatus.FORBIDDEN); } private async getMembership(userId: string, organizationId: string) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 760b32c..ba37dc5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,6 @@ { "common": { - "appName": "DyoLink", + "appName": "Nudentic", "loading": "Loading...", "loadingApp": "Loading app...", "loadingWorkspace": "Loading workspace...", @@ -119,7 +119,8 @@ "labelCreatePassword": "Create password", "labelConfirmPassword": "Confirm password", "activateAccount": "Activate account", - "invitationAcceptedRedirect": "Invitation Accepted. Redirecting to login...", + "invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...", + "invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.", "errorAcceptInvitation": "Could not accept invitation", "alreadyHaveAccess": "Already have access?", "goToLogin": "Go to login", @@ -128,7 +129,7 @@ "invitedBy": "Invited by:", "ownerEmail": "Owner email", "activateOrganization": "Activate organization", - "organizationAcceptedRedirect": "Invitation accepted. Redirecting to login...", + "organizationAcceptedRedirect": "Invitation accepted. Opening your workspace...", "stepAccount": "Account", "stepOrganization": "Organization", "organizationName": "Organization name", @@ -167,7 +168,7 @@ "featureRealtimeDescription": "Get instant notifications on case status changes.", "featureSecurityTitle": "Secure & Compliant", "featureSecurityDescription": "HIPAA-compliant with enterprise-grade security.", - "footerCopyright": "© 2026 DyoLink. All rights reserved.", + "footerCopyright": "© 2026 Nudentic. All rights reserved.", "termsAndConditions": "Terms & Conditions" }, "accountMenu": { @@ -492,7 +493,29 @@ "caseSheetNoComments": "No comments.", "errorDownloadCaseSheet": "Could not download the case sheet PDF.", "externalCodePlaceholder": "external code e.g. exocad code", - "errorUpdateExternalCode": "Could not save the external code." + "errorUpdateExternalCode": "Could not save the external code.", + "addCase": "Add case", + "addCaseTitle": "New case", + "addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.", + "startCase": "Start", + "draftBadge": "Draft", + "receivedBadge": "Received", + "generatedBadge": "Generated", + "referringClinic": "Referring clinic", + "referringDentist": "Dentist", + "patientName": "Patient name", + "partnerClinic": "Linked clinic", + "partnerClinicNone": "None", + "dueDateField": "Due date", + "caseLinesTitle": "Work lines", + "addLine": "Add line", + "lineChip": "Line {n}", + "removeLineAria": "Remove line {n}", + "errorCreateCase": "Could not create the case.", + "errorSaveCase": "Could not save the case.", + "errorStartCase": "Could not start the case.", + "confirmDeleteDraftCase": "Delete this draft case? This cannot be undone.", + "errorDeleteCase": "Could not delete the case." }, "labCaseAccess": { "pageTitle": "Case tasks", @@ -523,6 +546,10 @@ "searchPlaceholder": "Search patient or clinic…", "filterClinic": "Clinic", "filterClinicAll": "All clinics", + "filterOrigin": "Case source", + "filterOriginAll": "All sources", + "filterOriginReceived": "Received", + "filterOriginGenerated": "Generated", "filterStatus": "Status", "filterStatusAll": "All statuses", "filterStepCompleted": "Step completed", @@ -651,7 +678,6 @@ "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", "title": "Treatment", "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", - "subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.", "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", "selectedPatient": "Selected patient", @@ -675,16 +701,27 @@ "showAppointments": "Show appointments", "appointmentsTitle": "My appointments", "hideAppointments": "Hide appointments", - "emptyDay": "No appointments assigned to you on this day.", + "emptyDay": "No appointments or unscheduled treatments assigned to you on this day.", + "unscheduledHeading": "Without appointment", + "noAppointment": "No appointment", + "walkIn": "Walk-in", + "newTreatment": "New treatment", + "newTreatmentPatientPrompt": "Who is this visit for?", + "walkInPickerHint": "No named patient — always available, no search needed.", + "errorCreateTreatment": "Could not create treatment.", + "deleteEmptyTreatment": "Delete empty treatment", + "confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.", + "errorDeleteTreatment": "Could not delete treatment.", "casesTitle": "Treatment cases", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", "detailsTitle": "Treatment details", - "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "addDetail": "Add detail", "confirmRemoveDetail": "Remove this treatment detail?", "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", + "detailUnsentBadge": "unsent", + "attachmentPreviewUnavailable": "Preview unavailable", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", "detailsSaveHint": "Lab dispatch is configured separately below.", "addCase": "Add case", @@ -710,7 +747,7 @@ "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", - "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", + "labDispatchSubtitle": "Choose a lab, set prosthesis types, and send.", "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.", "addLabShipment": "Add lab shipment", "labShipmentLabel": "Shipment {n}", @@ -826,18 +863,30 @@ "entryStepTeeth": "Teeth", "entryStepContent": "Content", "entryStepLab": "Lab", + "entryStepTreatment": "Treatment", "entryStepBack": "Back", "entryStepNext": "Next", + "continueToLab": "Continue to lab", + "backToTreatment": "Back to treatment", + "selectTypeBeforeTeeth": "Choose a treatment type before selecting teeth.", + "labDispatchNotSent": "Not sent", + "labDispatchLastLab": "Last lab: {name}", + "labCommentsMovedHint": "Progress and comments are in Lab shipments.", "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to select or deselect. Shift-click another tooth in the same arch for a connected bridge. Click a bridge tooth to cut it out (still selected as a single); ends keep one bridge, middle splits into two.", + "toothChartHint": "Tap teeth to select or deselect. When two neighbors are selected, an empty circle appears between them — tap it to connect (or tap a filled circle to disconnect). Shift-click a range to select all in between (not connected until you fill the circles).", + "toothChartHelpAria": "How to select teeth", "toothConnectedHint": "Connected selection", + "toothLinkHint": "Connect these teeth", + "toothUnlinkHint": "Disconnect these teeth", + "toothLinkAria": "Connect teeth {a} and {b}", + "toothUnlinkAria": "Disconnect teeth {a} and {b}", "toothConnectedSuffix": " (connected)", "connectedBadge": "Connected", "prosthesisConnectedLabel": "Connected teeth prosthesis type", "toothChartWholePlan": "Full View", "labShipmentAttachments": "Files for the lab", - "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", + "labShipmentAttachmentsHint": "All files are included. Uncheck any you do not want to send.", "selectedLabel": "Selected:", "selectedEmpty": "—", "upperArch": "Upper arch", @@ -849,7 +898,7 @@ }, "organizations": { "loadingOrganization": "Loading organization...", - "subtitle": "Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.", + "subtitle": "Search organizations, send connection requests to existing accounts, or invitation links when they are not on Nudentic yet.", "invitationHistory": "Invitation History", "searchPlaceholder": "Search {counterpart} by name, email, or phone...", "backToList": "Back to list", @@ -943,7 +992,7 @@ "passwordChanged": "Password updated. Please sign in again.", "passwordChangeFailed": "Could not update password. Please try again.", "subscriptionsTitle": "Subscriptions", - "subscriptionsSubtitle": "Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.", + "subscriptionsSubtitle": "Your Nudentic workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.", "noSubscriptionNotice": "This organization has no active subscription. Select a plan below to start the purchase process.", "currentPlan": "Current plan", "planPrice": "Plan price", @@ -1075,12 +1124,116 @@ "VALIDATION_FIELD_REQUIRED": "Please fill in all required fields.", "VALIDATION_LANGUAGE_INVALID": "Please select a supported language.", "VALIDATION_INVALID_REQUEST": "The request contains invalid data.", + "VALIDATION_TIMEZONE_INVALID": "Your timezone could not be determined. Refresh the page and try again.", + "APPOINTMENT_INVALID_TIME": "The appointment start or end time is invalid.", + "APPOINTMENT_END_BEFORE_START": "End time must be after start time.", + "APPOINTMENT_TOO_LONG": "An appointment cannot last more than 24 hours.", + "APPOINTMENT_IN_PAST": "You cannot book an appointment in the past.", + "APPOINTMENT_INVALID_RANGE": "The date range is invalid.", + "APPOINTMENT_INVALID_RANGE_ORDER": "The end of the range must be after the start.", + "APPOINTMENT_INVALID_DATE": "The selected date is invalid.", + "APPOINTMENT_PROVIDER_NOT_MEMBER": "This provider is not a member of the organization.", + "APPOINTMENT_PROVIDER_INACTIVE": "This provider is not an active staff member.", + "APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "This provider cannot take treatment appointments.", + "APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "This provider has no working hours set.", + "APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "This provider does not work on the selected day.", + "APPOINTMENT_OUTSIDE_WORKING_HOURS": "This time is outside the provider’s working hours.", + "APPOINTMENT_NOT_FOUND": "Appointment not found.", + "APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.", + "PATIENT_NOT_FOUND": "Patient not found.", + "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.", + "STAFF_UNKNOWN_PERMISSIONS": "One or more selected permissions are not valid.", + "STAFF_NO_SUBSCRIPTION": "Choose a plan before adding staff.", + "STAFF_SEAT_LIMIT": "Your plan has no free seats. Disable a member or upgrade to add more.", + "STAFF_INVITE_OWNER_EMAIL": "The organization owner is already a member.", + "STAFF_ALREADY_MEMBER": "This person is already a member.", + "STAFF_OWNER_NO_INVITE_LINK": "The owner does not use an invitation link.", + "STAFF_ALREADY_ACCEPTED": "This member has already accepted their invitation.", + "STAFF_INVITE_MISSING": "No invitation was found for this member.", + "STAFF_INVITE_ALREADY_ACCEPTED": "This invitation has already been accepted.", + "STAFF_INVITE_INVALID": "This invitation is no longer valid.", + "STAFF_INVITE_REVOKED": "This invitation has been revoked.", + "STAFF_INVITE_EXPIRED": "This invitation has expired.", + "STAFF_ALREADY_ACTIVE": "This member is already active.", + "STAFF_ALREADY_DISABLED": "This member is already disabled or pending.", + "STAFF_CANNOT_DISABLE_SELF": "You cannot disable your own access.", + "STAFF_MEMBER_NOT_FOUND": "Staff member not found.", + "STAFF_CANNOT_EDIT_OWNER": "The owner’s membership cannot be edited here.", + "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.", + "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.", + "ORG_LINK_EXISTS": "These organizations are already linked.", + "ORG_REQUEST_NOT_PENDING": "Only pending connection requests can be answered.", + "ORG_CANNOT_RESPOND_OWN": "You cannot respond to your own connection request.", + "ORG_INVITE_ALREADY_ACCEPTED": "This invitation has already been accepted.", + "ORG_INVITE_INVALID": "This invitation is no longer valid.", + "ORG_INVITE_REVOKED": "This invitation has been revoked.", + "ORG_INVITE_EXPIRED": "This invitation has expired.", + "ORG_INVITE_TYPE_MISMATCH": "Organization type does not match this invitation.", + "ORG_INVITE_OWNER_HAS_ORG": "This owner already has an organization. Find it in search instead of sending an invitation.", + "ORG_UNKNOWN_TYPE": "Unknown organization type.", + "ORG_COUNTERPART_NOT_LAB": "The connected organization is not a lab.", + "ORG_COUNTERPART_NOT_CLINIC": "The connected organization is not a clinic.", + "ORG_CONNECTION_NOT_FOUND": "Connected organization not found.", + "ORG_INVITE_NOT_FOUND": "Invitation not found.", + "ORG_ONLY_CLINIC_COMMENT": "Only the clinic can comment on this case.", + "CATALOG_UNKNOWN_TREATMENT_TYPE": "Unknown treatment type.", + "CATALOG_UNKNOWN_PROSTHESIS_TYPE": "Unknown prosthesis type.", + "CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "This treatment type is completed in the clinic and cannot be sent to a lab.", + "TREATMENT_SAVE_DETAILS_BEFORE_LAB": "Save treatment details before creating lab cases.", + "TREATMENT_DETAIL_ONE_CASE": "Each treatment detail can belong to only one lab case.", + "TREATMENT_DETAILS_NOT_FOUND": "One or more treatment details were not found.", + "TREATMENT_DEST_NOT_LINKED": "That organization is not an active linked lab.", + "TREATMENT_TEETH_REQUIRED_TO_SHIP": "Select teeth before sending this work to a lab.", + "TREATMENT_TOOTH_UNKNOWN_DETAIL": "A tooth is linked to an unknown treatment detail.", + "TREATMENT_TOOTH_NOT_ON_DETAIL": "A selected tooth is not on this treatment detail.", + "TREATMENT_CASE_NO_DEST": "This lab case has no destination organization.", + "TREATMENT_CASE_NEEDS_DETAIL": "A lab case must include a treatment detail.", + "TREATMENT_CASE_ONE_DETAIL": "A lab case can include only one treatment detail.", + "TREATMENT_ONLY_PROVIDER_SEND": "Only the treatment provider can send this lab case.", + "TREATMENT_CASE_ALREADY_SENT": "This case was already sent to that lab.", + "TREATMENT_DUE_DATE_LOCKED": "The due date cannot be changed after all tasks are completed.", + "TREATMENT_INVALID_DUE_DATE": "The due date is invalid.", + "TREATMENT_DETAIL_KEY_REQUIRED": "A treatment detail is required for this upload.", + "TREATMENT_FILE_REQUIRED": "At least one file is required.", + "TREATMENT_ATTACHMENT_NOT_FOUND": "Attachment not found.", + "TREATMENT_FILE_UNAVAILABLE": "This file is no longer available.", + "TREATMENT_CASE_INVALID_ATTACHMENTS": "One or more attachments are not valid for this lab case.", + "TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "Each tooth needs a prosthesis type before sending.", + "LAB_CASE_NOT_FOUND": "Lab case not found.", + "TASK_ASSIGNEE_INVALID": "The selected person cannot be assigned this task.", + "TASK_ASSIGNED_TO_OTHER": "This task is assigned to another staff member.", + "TASK_PAGE_DATE_SORT_ONLY": "Jumping to a task in the list is only available when sorted by date.", + "INVALID_SENT_FROM": "The start date of the range is invalid.", + "INVALID_SENT_TO": "The end date of the range is invalid.", + "CASE_NOT_FOUND": "Case not found.", + "CASE_ATTACHMENT_NOT_FOUND": "Attachment not found.", + "CASE_ATTACHMENT_MISSING_FILE": "The attachment file is missing.", + "CASE_TASK_NOT_FOUND": "Task not found.", + "COMMENT_NOT_FOUND": "Comment not found.", + "COMMENT_VISIBILITY_LAB_ONLY": "Only lab comments can change visibility.", + "TODAY_INVALID_RANGE": "The date range is invalid.", + "TODAY_INVALID_RANGE_ORDER": "The end of the range must be after the start.", "NOT_FOUND": "The requested item was not found.", "CONFLICT": "This action conflicts with existing data.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", "TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.", + "TREATMENT_NOT_FOUND": "Treatment not found.", + "TREATMENT_PATIENT_OR_WALK_IN": "Choose a patient or create a walk-in treatment.", + "TREATMENT_NOT_STANDALONE": "Only treatments without an appointment can be deleted this way.", + "TREATMENT_HAS_DETAILS": "This treatment has details and cannot be deleted.", + "LAB_CASE_NOT_EDITABLE": "This case can no longer be edited.", + "LAB_CASE_ALREADY_STARTED": "This case has already been started.", + "LAB_CASE_LINES_REQUIRED": "Add at least one work line with teeth and a prosthesis type.", + "LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.", + "LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 17ea3d7..755adda 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1,6 +1,6 @@ { "common": { - "appName": "DyoLink", + "appName": "Nudentic", "loading": "در حال بارگذاری...", "loadingApp": "در حال بارگذاری برنامه...", "loadingWorkspace": "در حال بارگذاری فضای کاری...", @@ -119,7 +119,8 @@ "labelCreatePassword": "ایجاد رمز عبور", "labelConfirmPassword": "تأیید رمز عبور", "activateAccount": "فعال‌سازی حساب", - "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", + "invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.", "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", "alreadyHaveAccess": "از قبل دسترسی دارید؟", "goToLogin": "رفتن به ورود", @@ -128,7 +129,7 @@ "invitedBy": "دعوت‌کننده:", "ownerEmail": "ایمیل مالک", "activateOrganization": "فعال‌سازی سازمان", - "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", "stepAccount": "حساب", "stepOrganization": "سازمان", "organizationName": "نام سازمان", @@ -167,7 +168,7 @@ "featureRealtimeDescription": "اعلان‌های فوری درباره تغییر وضعیت پرونده‌ها دریافت کنید.", "featureSecurityTitle": "امن و منطبق با استانداردها", "featureSecurityDescription": "منطبق با HIPAA و دارای امنیت در سطح سازمانی.", - "footerCopyright": "© ۲۰۲۶ DyoLink. تمامی حقوق محفوظ است.", + "footerCopyright": "© ۲۰۲۶ Nudentic. تمامی حقوق محفوظ است.", "termsAndConditions": "شرایط و ضوابط" }, "accountMenu": { @@ -493,7 +494,29 @@ "caseSheetNoComments": "بدون توضیحات.", "errorDownloadCaseSheet": "دانلود PDF برگه پرونده ممکن نشد.", "externalCodePlaceholder": "کد خارجی مثلاً کد exocad", - "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد." + "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد.", + "addCase": "افزودن پرونده", + "addCaseTitle": "پرونده جدید", + "addCaseSubtitle": "کلینیک ارجاع‌دهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.", + "startCase": "شروع", + "draftBadge": "پیش‌نویس", + "receivedBadge": "دریافتی", + "generatedBadge": "ایجادشده", + "referringClinic": "کلینیک ارجاع‌دهنده", + "referringDentist": "دندان‌پزشک", + "patientName": "نام بیمار", + "partnerClinic": "کلینیک متصل", + "partnerClinicNone": "هیچ‌کدام", + "dueDateField": "موعد", + "caseLinesTitle": "خطوط کار", + "addLine": "افزودن خط", + "lineChip": "خط {n}", + "removeLineAria": "حذف خط {n}", + "errorCreateCase": "ایجاد پرونده ممکن نشد.", + "errorSaveCase": "ذخیره پرونده ممکن نشد.", + "errorStartCase": "شروع پرونده ممکن نشد.", + "confirmDeleteDraftCase": "این پیش‌نویس پرونده حذف شود؟ این کار قابل بازگشت نیست.", + "errorDeleteCase": "حذف پرونده ممکن نشد." }, "labCaseAccess": { "pageTitle": "وظایف پرونده", @@ -524,6 +547,10 @@ "searchPlaceholder": "جستجوی بیمار یا کلینیک…", "filterClinic": "کلینیک", "filterClinicAll": "همه کلینیک‌ها", + "filterOrigin": "منبع پرونده", + "filterOriginAll": "همه منابع", + "filterOriginReceived": "دریافتی", + "filterOriginGenerated": "ایجادشده", "filterStatus": "وضعیت", "filterStatusAll": "همه وضعیت‌ها", "filterStepCompleted": "مرحله تکمیل‌شده", @@ -652,7 +679,6 @@ "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", "title": "درمان", "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", - "subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.", "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", "selectedPatient": "بیمار انتخاب شده", @@ -676,16 +702,27 @@ "showAppointments": "نمایش نوبت‌ها", "appointmentsTitle": "نوبت‌های من", "hideAppointments": "پنهان کردن نوبت‌ها", - "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", + "emptyDay": "در این روز هیچ نوبت یا درمان بدون نوبتی به شما اختصاص داده نشده است.", + "unscheduledHeading": "بدون نوبت", + "noAppointment": "بدون نوبت", + "walkIn": "بدون نوبت (مراجع)", + "newTreatment": "درمان جدید", + "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", + "errorCreateTreatment": "ایجاد درمان ممکن نشد.", + "deleteEmptyTreatment": "حذف درمان خالی", + "confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.", + "errorDeleteTreatment": "حذف درمان ممکن نشد.", "casesTitle": "پرونده‌های درمانی", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", "detailsTitle": "جزئیات درمان", - "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", + "detailUnsentBadge": "ارسال‌نشده", + "attachmentPreviewUnavailable": "پیش‌نمایش در دسترس نیست", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.", "addCase": "افزودن پرونده", @@ -711,7 +748,7 @@ "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", - "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", + "labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.", "labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.", "addLabShipment": "افزودن محموله لاب", "labShipmentLabel": "محموله {n}", @@ -824,8 +861,13 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل می‌سازد. کلیک روی دندان داخل پل آن را جدا می‌کند (همچنان انتخاب‌شده به‌صورت تک)؛ انتها یک پل می‌ماند، وسط به دو پل تقسیم می‌شود.", + "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).", + "toothChartHelpAria": "راهنمای انتخاب دندان", "toothConnectedHint": "انتخاب متصل", + "toothLinkHint": "اتصال این دندان‌ها", + "toothUnlinkHint": "قطع اتصال این دندان‌ها", + "toothLinkAria": "اتصال دندان‌های {a} و {b}", + "toothUnlinkAria": "قطع اتصال دندان‌های {a} و {b}", "toothConnectedSuffix": " (متصل)", "connectedBadge": "متصل", "prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل", @@ -833,12 +875,19 @@ "entryStepTeeth": "دندان‌ها", "entryStepContent": "محتوا", "entryStepLab": "لابراتوار", + "entryStepTreatment": "درمان", "entryStepBack": "قبلی", "entryStepNext": "بعدی", + "continueToLab": "ادامه به لابراتوار", + "backToTreatment": "بازگشت به درمان", + "selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.", + "labDispatchNotSent": "ارسال نشده", + "labDispatchLastLab": "آخرین لاب: {name}", + "labCommentsMovedHint": "پیشرفت و پیام‌ها در بخش محموله‌های لاب هستند.", "entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.", "toothChartWholePlan": "نمایش کامل", "labShipmentAttachments": "فایل‌ها برای لابراتوار", - "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", + "labShipmentAttachmentsHint": "همه فایل‌ها پیوست می‌شوند. برای حذف از ارسال، تیک را بردارید.", "selectedLabel": "انتخاب شده:", "selectedEmpty": "—", "upperArch": "قوس بالا", @@ -850,7 +899,7 @@ }, "organizations": { "loadingOrganization": "در حال بارگذاری سازمان...", - "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در DyoLink نیستند، لینک دعوت ارسال کنید.", + "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در Nudentic نیستند، لینک دعوت ارسال کنید.", "invitationHistory": "تاریخچه دعوت‌نامه‌ها", "searchPlaceholder": "جستجوی {counterpart} بر اساس نام، ایمیل یا تلفن...", "backToList": "بازگشت به لیست", @@ -944,7 +993,7 @@ "passwordChanged": "رمز عبور به‌روزرسانی شد. لطفاً دوباره وارد شوید.", "passwordChangeFailed": "به‌روزرسانی رمز عبور انجام نشد. دوباره تلاش کنید.", "subscriptionsTitle": "اشتراک‌ها", - "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری DyoLink شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", + "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری Nudentic شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", "noSubscriptionNotice": "این سازمان اشتراک فعالی ندارد. برای شروع فرآیند خرید، یک طرح زیر را انتخاب کنید.", "currentPlan": "طرح فعلی", "planPrice": "قیمت طرح", @@ -1076,12 +1125,116 @@ "VALIDATION_FIELD_REQUIRED": "لطفاً همه فیلدهای الزامی را پر کنید.", "VALIDATION_LANGUAGE_INVALID": "زبان پشتیبانی‌شده انتخاب کنید.", "VALIDATION_INVALID_REQUEST": "درخواست حاوی داده نامعتبر است.", + "VALIDATION_TIMEZONE_INVALID": "منطقه زمانی شما مشخص نشد. صفحه را تازه‌سازی کنید و دوباره تلاش کنید.", + "APPOINTMENT_INVALID_TIME": "زمان شروع یا پایان نوبت نامعتبر است.", + "APPOINTMENT_END_BEFORE_START": "زمان پایان باید بعد از زمان شروع باشد.", + "APPOINTMENT_TOO_LONG": "مدت نوبت نمی‌تواند بیش از ۲۴ ساعت باشد.", + "APPOINTMENT_IN_PAST": "نمی‌توانید نوبت در گذشته ثبت کنید.", + "APPOINTMENT_INVALID_RANGE": "بازه تاریخ نامعتبر است.", + "APPOINTMENT_INVALID_RANGE_ORDER": "پایان بازه باید بعد از شروع باشد.", + "APPOINTMENT_INVALID_DATE": "تاریخ انتخاب‌شده نامعتبر است.", + "APPOINTMENT_PROVIDER_NOT_MEMBER": "این ارائه‌دهنده عضو سازمان نیست.", + "APPOINTMENT_PROVIDER_INACTIVE": "این ارائه‌دهنده عضو فعال پرسنل نیست.", + "APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "این ارائه‌دهنده نمی‌تواند نوبت درمان بپذیرد.", + "APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "برای این ارائه‌دهنده ساعات کاری تعریف نشده است.", + "APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "این ارائه‌دهنده در روز انتخاب‌شده کار نمی‌کند.", + "APPOINTMENT_OUTSIDE_WORKING_HOURS": "این زمان خارج از ساعات کاری ارائه‌دهنده است.", + "APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.", + "APPOINTMENT_NOT_PROVIDER": "شما ارائه‌دهنده این نوبت نیستید.", + "PATIENT_NOT_FOUND": "بیمار یافت نشد.", + "WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. هم‌پوشانی شیفت‌ها را بررسی کنید.", + "WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.", + "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبت‌های آینده تداخل دارد. ابتدا آن نوبت‌ها را تغییر دهید یا حذف کنید.", + "STAFF_UNKNOWN_PERMISSIONS": "یک یا چند دسترسی انتخاب‌شده نامعتبر است.", + "STAFF_NO_SUBSCRIPTION": "قبل از افزودن پرسنل یک طرح انتخاب کنید.", + "STAFF_SEAT_LIMIT": "در طرح شما صندلی خالی نیست. یک عضو را غیرفعال کنید یا طرح را ارتقا دهید.", + "STAFF_INVITE_OWNER_EMAIL": "مالک سازمان از قبل عضو است.", + "STAFF_ALREADY_MEMBER": "این فرد از قبل عضو است.", + "STAFF_OWNER_NO_INVITE_LINK": "مالک از لینک دعوت استفاده نمی‌کند.", + "STAFF_ALREADY_ACCEPTED": "این عضو دعوتنامه را پذیرفته است.", + "STAFF_INVITE_MISSING": "دعوتنامه‌ای برای این عضو یافت نشد.", + "STAFF_INVITE_ALREADY_ACCEPTED": "این دعوتنامه قبلاً پذیرفته شده است.", + "STAFF_INVITE_INVALID": "این دعوتنامه دیگر معتبر نیست.", + "STAFF_INVITE_REVOKED": "این دعوتنامه لغو شده است.", + "STAFF_INVITE_EXPIRED": "این دعوتنامه منقضی شده است.", + "STAFF_ALREADY_ACTIVE": "این عضو از قبل فعال است.", + "STAFF_ALREADY_DISABLED": "این عضو از قبل غیرفعال یا در انتظار است.", + "STAFF_CANNOT_DISABLE_SELF": "نمی‌توانید دسترسی خود را غیرفعال کنید.", + "STAFF_MEMBER_NOT_FOUND": "عضو پرسنل یافت نشد.", + "STAFF_CANNOT_EDIT_OWNER": "عضویت مالک را نمی‌توان از اینجا ویرایش کرد.", + "STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمی‌توان این‌گونه فعال کرد.", + "STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمی‌توان غیرفعال کرد.", + "STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمی‌توان حذف کرد.", + "ORG_CANNOT_LINK_SELF": "نمی‌توانید سازمان را به خودش متصل کنید.", + "ORG_LINK_WRONG_TYPE": "فقط می‌توانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.", + "ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.", + "ORG_LINK_EXISTS": "این سازمان‌ها از قبل متصل هستند.", + "ORG_REQUEST_NOT_PENDING": "فقط درخواست‌های در انتظار را می‌توان پاسخ داد.", + "ORG_CANNOT_RESPOND_OWN": "نمی‌توانید به درخواست اتصال خودتان پاسخ دهید.", + "ORG_INVITE_ALREADY_ACCEPTED": "این دعوتنامه قبلاً پذیرفته شده است.", + "ORG_INVITE_INVALID": "این دعوتنامه دیگر معتبر نیست.", + "ORG_INVITE_REVOKED": "این دعوتنامه لغو شده است.", + "ORG_INVITE_EXPIRED": "این دعوتنامه منقضی شده است.", + "ORG_INVITE_TYPE_MISMATCH": "نوع سازمان با این دعوتنامه مطابقت ندارد.", + "ORG_INVITE_OWNER_HAS_ORG": "این مالک از قبل سازمان دارد. به‌جای ارسال دعوتنامه آن را در جستجو پیدا کنید.", + "ORG_UNKNOWN_TYPE": "نوع سازمان ناشناخته است.", + "ORG_COUNTERPART_NOT_LAB": "سازمان متصل‌شده لابراتوار نیست.", + "ORG_COUNTERPART_NOT_CLINIC": "سازمان متصل‌شده کلینیک نیست.", + "ORG_CONNECTION_NOT_FOUND": "سازمان متصل یافت نشد.", + "ORG_INVITE_NOT_FOUND": "دعوتنامه یافت نشد.", + "ORG_ONLY_CLINIC_COMMENT": "فقط کلینیک می‌تواند روی این کیس نظر بگذارد.", + "CATALOG_UNKNOWN_TREATMENT_TYPE": "نوع درمان ناشناخته است.", + "CATALOG_UNKNOWN_PROSTHESIS_TYPE": "نوع پروتز ناشناخته است.", + "CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "این نوع درمان در کلینیک تکمیل می‌شود و قابل ارسال به لابراتوار نیست.", + "TREATMENT_SAVE_DETAILS_BEFORE_LAB": "قبل از ایجاد کیس لابراتوار، جزئیات درمان را ذخیره کنید.", + "TREATMENT_DETAIL_ONE_CASE": "هر جزء درمان فقط می‌تواند به یک کیس لابراتوار تعلق داشته باشد.", + "TREATMENT_DETAILS_NOT_FOUND": "یک یا چند جزء درمان یافت نشد.", + "TREATMENT_DEST_NOT_LINKED": "آن سازمان لابراتوار متصل فعال نیست.", + "TREATMENT_TEETH_REQUIRED_TO_SHIP": "قبل از ارسال به لابراتوار دندان‌ها را انتخاب کنید.", + "TREATMENT_TOOTH_UNKNOWN_DETAIL": "یک دندان به جزء درمان ناشناخته لینک شده است.", + "TREATMENT_TOOTH_NOT_ON_DETAIL": "دندان انتخاب‌شده روی این جزء درمان نیست.", + "TREATMENT_CASE_NO_DEST": "این کیس لابراتوار مقصد ندارد.", + "TREATMENT_CASE_NEEDS_DETAIL": "کیس لابراتوار باید شامل یک جزء درمان باشد.", + "TREATMENT_CASE_ONE_DETAIL": "کیس لابراتوار فقط می‌تواند یک جزء درمان داشته باشد.", + "TREATMENT_ONLY_PROVIDER_SEND": "فقط ارائه‌دهنده درمان می‌تواند این کیس را ارسال کند.", + "TREATMENT_CASE_ALREADY_SENT": "این کیس قبلاً به آن لابراتوار ارسال شده است.", + "TREATMENT_DUE_DATE_LOCKED": "پس از تکمیل همه کارها نمی‌توان موعد را تغییر داد.", + "TREATMENT_INVALID_DUE_DATE": "موعد نامعتبر است.", + "TREATMENT_DETAIL_KEY_REQUIRED": "برای این بارگذاری یک جزء درمان لازم است.", + "TREATMENT_FILE_REQUIRED": "حداقل یک فایل لازم است.", + "TREATMENT_ATTACHMENT_NOT_FOUND": "پیوست یافت نشد.", + "TREATMENT_FILE_UNAVAILABLE": "این فایل دیگر در دسترس نیست.", + "TREATMENT_CASE_INVALID_ATTACHMENTS": "یک یا چند پیوست برای این کیس معتبر نیست.", + "TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "قبل از ارسال، هر دندان باید نوع پروتز داشته باشد.", + "LAB_CASE_NOT_FOUND": "کیس لابراتوار یافت نشد.", + "TASK_ASSIGNEE_INVALID": "فرد انتخاب‌شده را نمی‌توان به این کار تخصیص داد.", + "TASK_ASSIGNED_TO_OTHER": "این کار به عضو دیگری تخصیص داده شده است.", + "TASK_PAGE_DATE_SORT_ONLY": "پرش به کار در فهرست فقط وقتی مرتب‌سازی بر اساس تاریخ است ممکن است.", + "INVALID_SENT_FROM": "تاریخ شروع بازه نامعتبر است.", + "INVALID_SENT_TO": "تاریخ پایان بازه نامعتبر است.", + "CASE_NOT_FOUND": "کیس یافت نشد.", + "CASE_ATTACHMENT_NOT_FOUND": "پیوست یافت نشد.", + "CASE_ATTACHMENT_MISSING_FILE": "فایل پیوست موجود نیست.", + "CASE_TASK_NOT_FOUND": "کار یافت نشد.", + "COMMENT_NOT_FOUND": "نظر یافت نشد.", + "COMMENT_VISIBILITY_LAB_ONLY": "فقط نظرات لابراتوار می‌توانند وضعیت نمایش را تغییر دهند.", + "TODAY_INVALID_RANGE": "بازه تاریخ نامعتبر است.", + "TODAY_INVALID_RANGE_ORDER": "پایان بازه باید بعد از شروع باشد.", "NOT_FOUND": "مورد درخواستی یافت نشد.", "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.", + "TREATMENT_NOT_FOUND": "درمان پیدا نشد.", + "TREATMENT_PATIENT_OR_WALK_IN": "یک بیمار انتخاب کنید یا درمان بدون نوبت بسازید.", + "TREATMENT_NOT_STANDALONE": "فقط درمان‌های بدون نوبت را می‌توان این‌گونه حذف کرد.", + "TREATMENT_HAS_DETAILS": "این درمان جزئیات دارد و قابل حذف نیست.", + "LAB_CASE_NOT_EDITABLE": "این پرونده دیگر قابل ویرایش نیست.", + "LAB_CASE_ALREADY_STARTED": "این پرونده قبلاً شروع شده است.", + "LAB_CASE_LINES_REQUIRED": "حداقل یک خط کار با دندان و نوع پروتز اضافه کنید.", + "LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندان‌ها و نوع پروتز را کامل کنید.", + "LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index bc00c67..6aae598 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -1,6 +1,6 @@ { "common": { - "appName": "DyoLink", + "appName": "Nudentic", "loading": "Laden...", "loadingApp": "Applicatie laden...", "loadingWorkspace": "Werkruimte laden...", @@ -119,7 +119,8 @@ "labelCreatePassword": "Wachtwoord aanmaken", "labelConfirmPassword": "Bevestig wachtwoord", "activateAccount": "Account activeren", - "invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar 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", "alreadyHaveAccess": "Heeft u al toegang?", "goToLogin": "Ga naar inloggen", @@ -128,7 +129,7 @@ "invitedBy": "Uitgenodigd door:", "ownerEmail": "E-mail eigenaar", "activateOrganization": "Organisatie activeren", - "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...", "stepAccount": "Account", "stepOrganization": "Organisatie", "organizationName": "Organisatienaam", @@ -167,7 +168,7 @@ "featureRealtimeDescription": "Ontvang direct meldingen bij statuswijzigingen van casussen.", "featureSecurityTitle": "Veilig & Conform", "featureSecurityDescription": "HIPAA-conform met beveiliging op ondernemingsniveau.", - "footerCopyright": "© 2026 DyoLink. Alle rechten voorbehouden.", + "footerCopyright": "© 2026 Nudentic. Alle rechten voorbehouden.", "termsAndConditions": "Algemene Voorwaarden" }, "accountMenu": { @@ -493,7 +494,29 @@ "caseSheetNoComments": "Geen opmerkingen.", "errorDownloadCaseSheet": "Case Sheet PDF kon niet worden gedownload.", "externalCodePlaceholder": "externe code bijv. exocad-code", - "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen." + "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen.", + "addCase": "Case toevoegen", + "addCaseTitle": "Nieuwe case", + "addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.", + "startCase": "Starten", + "draftBadge": "Concept", + "receivedBadge": "Ontvangen", + "generatedBadge": "Aangemaakt", + "referringClinic": "Verwijzende kliniek", + "referringDentist": "Tandarts", + "patientName": "Patiëntnaam", + "partnerClinic": "Gekoppelde kliniek", + "partnerClinicNone": "Geen", + "dueDateField": "Vervaldatum", + "caseLinesTitle": "Werklijnen", + "addLine": "Lijn toevoegen", + "lineChip": "Lijn {n}", + "removeLineAria": "Lijn {n} verwijderen", + "errorCreateCase": "Case kon niet worden aangemaakt.", + "errorSaveCase": "Case kon niet worden opgeslagen.", + "errorStartCase": "Case kon niet worden gestart.", + "confirmDeleteDraftCase": "Dit concept verwijderen? Dit kan niet ongedaan worden gemaakt.", + "errorDeleteCase": "Case kon niet worden verwijderd." }, "labCaseAccess": { "pageTitle": "Dossiertaken", @@ -524,6 +547,10 @@ "searchPlaceholder": "Zoek patiënt of kliniek…", "filterClinic": "Kliniek", "filterClinicAll": "Alle klinieken", + "filterOrigin": "Bron van case", + "filterOriginAll": "Alle bronnen", + "filterOriginReceived": "Ontvangen", + "filterOriginGenerated": "Aangemaakt", "filterStatus": "Status", "filterStatusAll": "Alle statussen", "filterStepCompleted": "Stap voltooid", @@ -651,7 +678,6 @@ "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", "title": "Behandeling", "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", - "subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.", "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", "selectedPatient": "Geselecteerde patiënt", @@ -675,16 +701,27 @@ "showAppointments": "Afspraken tonen", "appointmentsTitle": "Mijn afspraken", "hideAppointments": "Afspraken verbergen", - "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", + "emptyDay": "Geen afspraken of ongeplande behandelingen voor u op deze dag.", + "unscheduledHeading": "Zonder afspraak", + "noAppointment": "Geen afspraak", + "walkIn": "Inloop", + "newTreatment": "Nieuwe behandeling", + "newTreatmentPatientPrompt": "Voor wie is dit bezoek?", + "walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.", + "errorCreateTreatment": "Behandeling aanmaken is mislukt.", + "deleteEmptyTreatment": "Lege behandeling verwijderen", + "confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.", + "errorDeleteTreatment": "Behandeling kon niet worden verwijderd.", "casesTitle": "Behandelcasussen", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", "detailsTitle": "Behandeldetails", - "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "addDetail": "Detail toevoegen", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", + "detailUnsentBadge": "niet verzonden", + "attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.", "addCase": "Case toevoegen", @@ -710,7 +747,7 @@ "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", - "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", + "labDispatchSubtitle": "Kies een lab, stel prothesetypes in en verstuur.", "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.", "addLabShipment": "Labzending toevoegen", "labShipmentLabel": "Zending {n}", @@ -823,8 +860,13 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om te selecteren of deselecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden brug. Klik op een brugtand om die eruit te snijden (blijft geselecteerd als single); uiteinden houden één brug, midden splitst in twee.", + "toothChartHint": "Tik op tanden om te selecteren of deselecteren. Bij twee geselecteerde buren verschijnt een lege cirkel ertussen — tik om te verbinden (of tik op een gevulde cirkel om te ontkoppelen). Shift-klik een bereik om alles ertussen te selecteren (nog niet verbonden tot je de cirkels vult).", + "toothChartHelpAria": "Tanden selecteren", "toothConnectedHint": "Verbonden selectie", + "toothLinkHint": "Deze tanden verbinden", + "toothUnlinkHint": "Deze tanden ontkoppelen", + "toothLinkAria": "Tanden {a} en {b} verbinden", + "toothUnlinkAria": "Tanden {a} en {b} ontkoppelen", "toothConnectedSuffix": " (verbonden)", "connectedBadge": "Verbonden", "prosthesisConnectedLabel": "Prothesetype voor verbonden tanden", @@ -832,12 +874,19 @@ "entryStepTeeth": "Tanden", "entryStepContent": "Inhoud", "entryStepLab": "Lab", + "entryStepTreatment": "Behandeling", "entryStepBack": "Terug", "entryStepNext": "Volgende", + "continueToLab": "Verder naar lab", + "backToTreatment": "Terug naar behandeling", + "selectTypeBeforeTeeth": "Kies een behandeltype voordat u tanden selecteert.", + "labDispatchNotSent": "Niet verzonden", + "labDispatchLastLab": "Laatste lab: {name}", + "labCommentsMovedHint": "Voortgang en berichten staan bij Labzendingen.", "entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.", "toothChartWholePlan": "Volledig overzicht", "labShipmentAttachments": "Bestanden voor het lab", - "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", + "labShipmentAttachmentsHint": "Alle bestanden gaan mee. Vink uit wat u niet wilt meesturen.", "selectedLabel": "Geselecteerd:", "selectedEmpty": "—", "upperArch": "Bovenboog", @@ -849,7 +898,7 @@ }, "organizations": { "loadingOrganization": "Organisatie laden...", - "subtitle": "Zoek organisaties, stuur verbindingsverzoeken naar bestaande accounts, of uitnodigingslinks wanneer ze nog niet op DyoLink staan.", + "subtitle": "Zoek organisaties, stuur verbindingsverzoeken naar bestaande accounts, of uitnodigingslinks wanneer ze nog niet op Nudentic staan.", "invitationHistory": "Uitnodigingsgeschiedenis", "searchPlaceholder": "Zoek {counterpart} op naam, e-mail of telefoon...", "backToList": "Terug naar lijst", @@ -943,7 +992,7 @@ "passwordChanged": "Wachtwoord bijgewerkt. Log opnieuw in.", "passwordChangeFailed": "Wachtwoord kon niet worden bijgewerkt. Probeer het opnieuw.", "subscriptionsTitle": "Abonnementen", - "subscriptionsSubtitle": "Uw DyoLink-werkruimteplan en plaatsen voor {orgName}. Kliniek- en laboratoriuminkomsten blijven onder het tabblad Facturatie in de zijbalk.", + "subscriptionsSubtitle": "Uw Nudentic-werkruimteplan en plaatsen voor {orgName}. Kliniek- en laboratoriuminkomsten blijven onder het tabblad Facturatie in de zijbalk.", "noSubscriptionNotice": "Deze organisatie heeft geen actief abonnement. Selecteer hieronder een abonnement om het aankoopproces te starten.", "currentPlan": "Huidig abonnement", "planPrice": "Abonnementsprijs", @@ -1075,12 +1124,116 @@ "VALIDATION_FIELD_REQUIRED": "Vul alle verplichte velden in.", "VALIDATION_LANGUAGE_INVALID": "Selecteer een ondersteunde taal.", "VALIDATION_INVALID_REQUEST": "Het verzoek bevat ongeldige gegevens.", + "VALIDATION_TIMEZONE_INVALID": "Uw tijdzone kon niet worden bepaald. Vernieuw de pagina en probeer opnieuw.", + "APPOINTMENT_INVALID_TIME": "De start- of eindtijd van de afspraak is ongeldig.", + "APPOINTMENT_END_BEFORE_START": "De eindtijd moet na de starttijd liggen.", + "APPOINTMENT_TOO_LONG": "Een afspraak mag niet langer dan 24 uur duren.", + "APPOINTMENT_IN_PAST": "U kunt geen afspraak in het verleden boeken.", + "APPOINTMENT_INVALID_RANGE": "Het datumbereik is ongeldig.", + "APPOINTMENT_INVALID_RANGE_ORDER": "Het einde van het bereik moet na het begin liggen.", + "APPOINTMENT_INVALID_DATE": "De geselecteerde datum is ongeldig.", + "APPOINTMENT_PROVIDER_NOT_MEMBER": "Deze zorgverlener is geen lid van de organisatie.", + "APPOINTMENT_PROVIDER_INACTIVE": "Deze zorgverlener is geen actief teamlid.", + "APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "Deze zorgverlener kan geen behandelafspraken aannemen.", + "APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "Voor deze zorgverlener zijn geen werktijden ingesteld.", + "APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "Deze zorgverlener werkt niet op de geselecteerde dag.", + "APPOINTMENT_OUTSIDE_WORKING_HOURS": "Dit tijdstip valt buiten de werktijden van de zorgverlener.", + "APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.", + "APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.", + "PATIENT_NOT_FOUND": "Patiënt niet gevonden.", + "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.", + "STAFF_UNKNOWN_PERMISSIONS": "Een of meer geselecteerde rechten zijn ongeldig.", + "STAFF_NO_SUBSCRIPTION": "Kies een abonnement voordat u personeel toevoegt.", + "STAFF_SEAT_LIMIT": "Uw abonnement heeft geen vrije plaatsen. Schakel een lid uit of upgrade.", + "STAFF_INVITE_OWNER_EMAIL": "De eigenaar van de organisatie is al lid.", + "STAFF_ALREADY_MEMBER": "Deze persoon is al lid.", + "STAFF_OWNER_NO_INVITE_LINK": "De eigenaar gebruikt geen uitnodigingslink.", + "STAFF_ALREADY_ACCEPTED": "Dit lid heeft de uitnodiging al geaccepteerd.", + "STAFF_INVITE_MISSING": "Er is geen uitnodiging voor dit lid gevonden.", + "STAFF_INVITE_ALREADY_ACCEPTED": "Deze uitnodiging is al geaccepteerd.", + "STAFF_INVITE_INVALID": "Deze uitnodiging is niet meer geldig.", + "STAFF_INVITE_REVOKED": "Deze uitnodiging is ingetrokken.", + "STAFF_INVITE_EXPIRED": "Deze uitnodiging is verlopen.", + "STAFF_ALREADY_ACTIVE": "Dit lid is al actief.", + "STAFF_ALREADY_DISABLED": "Dit lid is al uitgeschakeld of in afwachting.", + "STAFF_CANNOT_DISABLE_SELF": "U kunt uw eigen toegang niet uitschakelen.", + "STAFF_MEMBER_NOT_FOUND": "Teamlid niet gevonden.", + "STAFF_CANNOT_EDIT_OWNER": "Het lidmaatschap van de eigenaar kan hier niet worden bewerkt.", + "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.", + "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.", + "ORG_LINK_EXISTS": "Deze organisaties zijn al gekoppeld.", + "ORG_REQUEST_NOT_PENDING": "Alleen openstaande verbindingsverzoeken kunnen worden beantwoord.", + "ORG_CANNOT_RESPOND_OWN": "U kunt niet op uw eigen verbindingsverzoek reageren.", + "ORG_INVITE_ALREADY_ACCEPTED": "Deze uitnodiging is al geaccepteerd.", + "ORG_INVITE_INVALID": "Deze uitnodiging is niet meer geldig.", + "ORG_INVITE_REVOKED": "Deze uitnodiging is ingetrokken.", + "ORG_INVITE_EXPIRED": "Deze uitnodiging is verlopen.", + "ORG_INVITE_TYPE_MISMATCH": "Het organisatietype komt niet overeen met deze uitnodiging.", + "ORG_INVITE_OWNER_HAS_ORG": "Deze eigenaar heeft al een organisatie. Zoek die in plaats van een uitnodiging te sturen.", + "ORG_UNKNOWN_TYPE": "Onbekend organisatietype.", + "ORG_COUNTERPART_NOT_LAB": "De gekoppelde organisatie is geen lab.", + "ORG_COUNTERPART_NOT_CLINIC": "De gekoppelde organisatie is geen kliniek.", + "ORG_CONNECTION_NOT_FOUND": "Gekoppelde organisatie niet gevonden.", + "ORG_INVITE_NOT_FOUND": "Uitnodiging niet gevonden.", + "ORG_ONLY_CLINIC_COMMENT": "Alleen de kliniek kan reageren op deze case.", + "CATALOG_UNKNOWN_TREATMENT_TYPE": "Onbekend behandelingstype.", + "CATALOG_UNKNOWN_PROSTHESIS_TYPE": "Onbekend prothesetype.", + "CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "Dit behandelingstype wordt in de kliniek afgerond en kan niet naar een lab worden gestuurd.", + "TREATMENT_SAVE_DETAILS_BEFORE_LAB": "Sla behandeldetails op voordat u labcases aanmaakt.", + "TREATMENT_DETAIL_ONE_CASE": "Elk behandeldetail kan tot slechts één labcase behoren.", + "TREATMENT_DETAILS_NOT_FOUND": "Een of meer behandeldetails zijn niet gevonden.", + "TREATMENT_DEST_NOT_LINKED": "Die organisatie is geen actief gekoppeld lab.", + "TREATMENT_TEETH_REQUIRED_TO_SHIP": "Selecteer tanden voordat u dit werk naar een lab stuurt.", + "TREATMENT_TOOTH_UNKNOWN_DETAIL": "Een tand is gekoppeld aan een onbekend behandeldetail.", + "TREATMENT_TOOTH_NOT_ON_DETAIL": "Een geselecteerde tand staat niet op dit behandeldetail.", + "TREATMENT_CASE_NO_DEST": "Deze labcase heeft geen bestemmingsorganisatie.", + "TREATMENT_CASE_NEEDS_DETAIL": "Een labcase moet een behandeldetail bevatten.", + "TREATMENT_CASE_ONE_DETAIL": "Een labcase mag slechts één behandeldetail bevatten.", + "TREATMENT_ONLY_PROVIDER_SEND": "Alleen de behandelaar kan deze labcase versturen.", + "TREATMENT_CASE_ALREADY_SENT": "Deze case is al naar dat lab gestuurd.", + "TREATMENT_DUE_DATE_LOCKED": "De deadline kan niet worden gewijzigd nadat alle taken zijn voltooid.", + "TREATMENT_INVALID_DUE_DATE": "De deadline is ongeldig.", + "TREATMENT_DETAIL_KEY_REQUIRED": "Voor deze upload is een behandeldetail vereist.", + "TREATMENT_FILE_REQUIRED": "Er is minstens één bestand vereist.", + "TREATMENT_ATTACHMENT_NOT_FOUND": "Bijlage niet gevonden.", + "TREATMENT_FILE_UNAVAILABLE": "Dit bestand is niet meer beschikbaar.", + "TREATMENT_CASE_INVALID_ATTACHMENTS": "Een of meer bijlagen zijn ongeldig voor deze labcase.", + "TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "Elke tand heeft een prothesetype nodig voordat u verstuurt.", + "LAB_CASE_NOT_FOUND": "Labcase niet gevonden.", + "TASK_ASSIGNEE_INVALID": "De geselecteerde persoon kan deze taak niet toegewezen krijgen.", + "TASK_ASSIGNED_TO_OTHER": "Deze taak is aan een ander teamlid toegewezen.", + "TASK_PAGE_DATE_SORT_ONLY": "Naar een taak springen kan alleen bij sorteren op datum.", + "INVALID_SENT_FROM": "De startdatum van het bereik is ongeldig.", + "INVALID_SENT_TO": "De einddatum van het bereik is ongeldig.", + "CASE_NOT_FOUND": "Case niet gevonden.", + "CASE_ATTACHMENT_NOT_FOUND": "Bijlage niet gevonden.", + "CASE_ATTACHMENT_MISSING_FILE": "Het bijlagebestand ontbreekt.", + "CASE_TASK_NOT_FOUND": "Taak niet gevonden.", + "COMMENT_NOT_FOUND": "Reactie niet gevonden.", + "COMMENT_VISIBILITY_LAB_ONLY": "Alleen labreacties kunnen de zichtbaarheid wijzigen.", + "TODAY_INVALID_RANGE": "Het datumbereik is ongeldig.", + "TODAY_INVALID_RANGE_ORDER": "Het einde van het bereik moet na het begin liggen.", "NOT_FOUND": "Het gevraagde item is niet gevonden.", "CONFLICT": "Deze actie conflicteert met bestaande gegevens.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.", + "TREATMENT_NOT_FOUND": "Behandeling niet gevonden.", + "TREATMENT_PATIENT_OR_WALK_IN": "Kies een patiënt of maak een inloopbehandeling.", + "TREATMENT_NOT_STANDALONE": "Alleen behandelingen zonder afspraak kunnen op deze manier worden verwijderd.", + "TREATMENT_HAS_DETAILS": "Deze behandeling heeft details en kan niet worden verwijderd.", + "LAB_CASE_NOT_EDITABLE": "Deze case kan niet meer worden bewerkt.", + "LAB_CASE_ALREADY_STARTED": "Deze case is al gestart.", + "LAB_CASE_LINES_REQUIRED": "Voeg minstens één werkregel toe met tanden en een prothesetype.", + "LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.", + "LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/public/file.svg b/frontend/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/frontend/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/scripts/build-brand-components.mjs b/frontend/scripts/build-brand-components.mjs new file mode 100644 index 0000000..aa6c528 --- /dev/null +++ b/frontend/scripts/build-brand-components.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const brand = path.join(root, 'src/assets/brand'); + +function stripBom(s) { + return s.replace(/^\uFEFF/, ''); +} + +function svgToInner(svg) { + return svg + .replace(/<\?xml[^>]*>/, '') + .replace(/]*>/, '') + .replace(/<\/svg>\s*$/, '') + .replace(/[^<]*<\/title>\s*/, '') + .trim() + .replace(/fill-rule=/g, 'fillRule='); +} + +const wordmark = stripBom(fs.readFileSync(path.join(brand, 'wordmark.svg'), 'utf8')); +const logo = stripBom(fs.readFileSync(path.join(brand, 'logo.svg'), 'utf8')); +const wmInner = svgToInner(wordmark); +const lgInner = svgToInner(logo); + +const wordmarkTsx = `import type { CSSProperties } from 'react'; + +export const BRAND_NAME = 'Nudentic'; + +type BrandWordmarkProps = { + className?: string; + style?: CSSProperties; + decorative?: boolean; +}; + +/** Official wordmark from \`src/assets/brand/wordmark.svg\`. Re-run \`scripts/build-brand-components.mjs\` after editing the SVG. */ +export function BrandWordmark({ className, style, decorative }: BrandWordmarkProps) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 345 70" + className={className} + style={{ display: 'inline-block', height: '1em', width: 'auto', overflow: 'visible', ...style }} + role={decorative ? undefined : 'img'} + aria-label={decorative ? undefined : BRAND_NAME} + aria-hidden={decorative || undefined} + > + ${wmInner} + </svg> + ); +} +`; + +const logoTsx = `import type { CSSProperties } from 'react'; +import { BRAND_NAME } from '@/components/ui/shared/BrandWordmark'; + +type BrandLogoProps = { + className?: string; + style?: CSSProperties; + decorative?: boolean; +}; + +/** Official mark from \`src/assets/brand/logo.svg\`. Re-run \`scripts/build-brand-components.mjs\` after editing the SVG. */ +export function BrandLogo({ className, style, decorative }: BrandLogoProps) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 4294 3630" + className={className} + style={{ display: 'inline-block', height: '1.15em', width: 'auto', overflow: 'visible', ...style }} + role={decorative ? undefined : 'img'} + aria-label={decorative ? undefined : BRAND_NAME} + aria-hidden={decorative || undefined} + > + ${lgInner} + </svg> + ); +} +`; + +const lockupTsx = `import type { CSSProperties } from 'react'; +import { BrandLogo } from '@/components/ui/shared/BrandLogo'; +import { BrandWordmark, BRAND_NAME } from '@/components/ui/shared/BrandWordmark'; + +type BrandLockupProps = { + className?: string; + style?: CSSProperties; +}; + +/** Logo + wordmark. Size via font-size / text-* on this wrapper (\`em\` on the SVGs). */ +export function BrandLockup({ className, style }: BrandLockupProps) { + return ( + <span + dir="ltr" + className={className} + style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35em', ...style }} + role="img" + aria-label={BRAND_NAME} + > + <BrandLogo decorative /> + <BrandWordmark decorative /> + </span> + ); +} +`; + +fs.writeFileSync(path.join(root, 'src/components/ui/shared/BrandWordmark.tsx'), wordmarkTsx); +fs.writeFileSync(path.join(root, 'src/components/ui/shared/BrandLogo.tsx'), logoTsx); +fs.writeFileSync(path.join(root, 'src/components/ui/shared/BrandLockup.tsx'), lockupTsx); + +const icon = logo + .replaceAll('fill="currentColor"', 'fill="#0f172a"') + .replace(/<title>[^<]*<\/title>\s*/, ''); +fs.writeFileSync(path.join(root, 'src/app/icon.svg'), icon); + +console.log('brand components + icon.svg written'); diff --git a/frontend/scripts/extract-tooth-svgs.mjs b/frontend/scripts/extract-tooth-svgs.mjs new file mode 100644 index 0000000..0eadb77 --- /dev/null +++ b/frontend/scripts/extract-tooth-svgs.mjs @@ -0,0 +1,54 @@ +/** + * Regenerates realisticToothAssets.ts from FDI_*.svg files in src/assets/fdi/. + * Usage (from frontend/): node scripts/extract-tooth-svgs.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const dir = path.resolve('src/assets/fdi'); +const files = fs.readdirSync(dir).filter((f) => /^FDI_\d{2}\.svg$/i.test(f)); +const out = {}; + +for (const file of files.sort()) { + const id = file.match(/FDI_(\d{2})/i)?.[1]; + if (!id) continue; + const xml = fs.readFileSync(path.join(dir, file), 'utf8'); + const vb = xml.match(/viewBox="([^"]+)"/)?.[1]; + const toothG = xml.match(/<g id="tooth-\d+"[^>]*>/)?.[0] ?? ''; + const transform = toothG.match(/transform="([^"]+)"/)?.[1]; + const rootD = xml.match(/id="root-path"[\s\S]*?d="([^"]+)"/)?.[1]; + const crownD = xml.match(/id="crown-path"[\s\S]*?d="([^"]+)"/)?.[1]; + if (!vb || !rootD || !crownD) throw new Error(`Failed to parse ${file}`); + out[id] = { + viewBox: vb, + ...(transform ? { groupTransform: transform } : {}), + crown: crownD, + roots: [rootD], + }; + console.log(id, transform ? 'mirrored' : 'plain'); +} + +const ts = `import type { FdiToothId } from '@/types/treatment'; + +/** + * Realistic buccal-view assets from src/assets/fdi/FDI_*.svg. + * Upper (1x/2x): root-up. Lower (3x/4x): crown-up. + * Left chart side (Q1/Q4) includes horizontal flip via groupTransform. + */ +export interface RealisticToothAsset { + viewBox: string; + /** Applied inside the svg (e.g. Q1/Q4 mirror baked in source files). */ + groupTransform?: string; + crown: string; + roots: string[]; +} + +export const REALISTIC_TOOTH_ASSETS: Partial<Record<FdiToothId, RealisticToothAsset>> = ${JSON.stringify(out, null, 2)}; + +export function getRealisticToothAsset(fdi: FdiToothId): RealisticToothAsset | null { + return REALISTIC_TOOTH_ASSETS[fdi] ?? null; +} +`; + +fs.writeFileSync(path.resolve('src/components/treatment/realisticToothAssets.ts'), ts); +console.log('Wrote realisticToothAssets.ts for', Object.keys(out).length, 'teeth'); diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 6ad568e..cf86a72 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -9,12 +9,15 @@ import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { staffApi } from '@/lib/api/staff'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect'; function AcceptInviteContent() { const t = useTranslations('auth'); const tErrors = useTranslations('errors'); const params = useSearchParams(); const router = useRouter(); + const { login } = useAuth(); const token = useMemo(() => params.get('token') || '', [params]); const [loading, setLoading] = useState(true); @@ -76,18 +79,29 @@ function AcceptInviteContent() { } setSubmitting(true); + let accepted = false; try { await staffApi.acceptInvite({ token, name: name.trim(), password, }); - setSuccess(t('invitationAcceptedRedirect')); - setTimeout(() => { - router.replace('/login'); - }, 1000); + accepted = true; + const email = inviteInfo?.email?.trim(); + if (!email) { + throw new Error('missing-invite-email'); + } + const { organizations } = await login(email, password); + navigateIntoAppIfOrgSelected((href) => router.replace(href), organizations.length); } catch (e: unknown) { - setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + if (accepted) { + setSuccess(t('invitationAcceptedSignInFailed')); + setTimeout(() => { + router.replace('/login'); + }, 1500); + } else { + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + } } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 213af59..e07fe4f 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -10,12 +10,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Lock, Mail, User } from 'lucide-react'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; +import { BrandLockup } from '@/components/ui/shared/BrandLockup'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { organizationApi } from '@/lib/api/organization'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect'; type AcceptOrganizationInviteForm = { ownerName: string; @@ -33,6 +36,7 @@ function AcceptOrganizationInviteContent() { const tValidation = useTranslations('validation'); const params = useSearchParams(); const router = useRouter(); + const { login } = useAuth(); const token = useMemo(() => params.get('token') || '', [params]); const acceptOrganizationInviteSchema = useMemo( @@ -137,6 +141,7 @@ function AcceptOrganizationInviteContent() { setError(''); setSuccess(''); setSubmitting(true); + let accepted = false; try { await organizationApi.acceptInvite({ token, @@ -146,10 +151,20 @@ function AcceptOrganizationInviteContent() { organizationEmail: data.organizationEmail.trim(), organizationType: data.organizationType, }); - setSuccess(t('organizationAcceptedRedirect')); - setTimeout(() => router.replace('/login'), 1000); + accepted = true; + const email = inviteInfo?.ownerEmail?.trim(); + if (!email) { + throw new Error('missing-invite-email'); + } + const { organizations } = await login(email, data.password); + navigateIntoAppIfOrgSelected((href) => router.replace(href), organizations.length); } catch (e: unknown) { - setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + if (accepted) { + setSuccess(t('invitationAcceptedSignInFailed')); + setTimeout(() => router.replace('/login'), 1500); + } else { + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + } } finally { setSubmitting(false); } @@ -165,9 +180,7 @@ function AcceptOrganizationInviteContent() { header={ <> <Link href="/" className="flex justify-center"> - <span className="text-2xl sm:text-3xl font-semibold text-text-primary"> - {tCommon('appName')} - </span> + <BrandLockup className="text-2xl sm:text-3xl text-text-primary" /> </Link> <h2 className="mt-4 sm:mt-6 text-center text-2xl sm:text-3xl font-semibold text-text-primary"> {t('acceptOrganizationTitle')} diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx index 1f2aa19..b2b89a0 100644 --- a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -11,6 +11,7 @@ import { authApi } from '@/lib/api/auth'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; +import { BrandLockup } from '@/components/ui/shared/BrandLockup'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; @@ -28,7 +29,6 @@ function normalizeIranMobile(input: string): string { export default function ForgotPasswordPage() { const t = useTranslations('auth'); - const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); const router = useRouter(); @@ -128,9 +128,7 @@ export default function ForgotPasswordPage() { header={ <> <Link href="/" className="flex justify-center"> - <span className="text-2xl sm:text-3xl font-semibold text-text-primary"> - {tCommon('appName')} - </span> + <BrandLockup className="text-2xl sm:text-3xl text-text-primary" /> </Link> <h2 className="mt-4 sm:mt-6 text-center text-2xl sm:text-3xl font-semibold text-text-primary"> {t('forgotPasswordTitle')} diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index 1cd8f93..51dad4b 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useMemo } from 'react'; import { useSearchParams } from 'next/navigation'; -import { useRouter } from '@/i18n/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -13,13 +12,12 @@ import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { getRememberedEmail } from '@/lib/auth/rememberMe'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; +import { BrandLockup } from '@/components/ui/shared/BrandLockup'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Input } from '@/components/ui/shared/Input'; -import { - consumeAuthRedirect, - storeAuthRedirectFromPath, -} from '@/lib/auth/postAuthRedirect'; +import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; +import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated'; type LoginForm = { email: string; @@ -32,8 +30,7 @@ function LoginPageContent() { const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); - const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth(); - const router = useRouter(); + const { login, isLoading, isAuthReady } = useAuth(); const searchParams = useSearchParams(); const [error, setError] = useState<string | null>(null); const [savedEmail] = useState(() => getRememberedEmail()); @@ -55,12 +52,7 @@ function LoginPageContent() { } }, [searchParams]); - useEffect(() => { - if (!isAuthReady || !user || isLoading) return; - const orgReady = organizations.length <= 1 || currentOrganization; - if (!orgReady) return; - router.push(consumeAuthRedirect() ?? '/today'); - }, [isAuthReady, user, isLoading, organizations, currentOrganization, router]); + useEnterAppWhenAuthenticated(); const { register, @@ -100,9 +92,7 @@ function LoginPageContent() { header={ <> <Link href="/" className="flex justify-center"> - <span className="text-2xl sm:text-3xl font-semibold text-text-primary"> - {tCommon('appName')} - </span> + <BrandLockup className="text-2xl sm:text-3xl text-text-primary" /> </Link> <h2 className="mt-4 sm:mt-6 text-center text-2xl sm:text-3xl font-semibold text-text-primary"> {t('signInTitle')} diff --git a/frontend/src/app/[locale]/(public)/page.tsx b/frontend/src/app/[locale]/(public)/page.tsx index 5f620c9..3136e02 100644 --- a/frontend/src/app/[locale]/(public)/page.tsx +++ b/frontend/src/app/[locale]/(public)/page.tsx @@ -5,20 +5,20 @@ import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Button } from '@/components/ui/shared/Button'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; +import { BrandLockup } from '@/components/ui/shared/BrandLockup'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { const t = useTranslations('landing'); const tAuth = useTranslations('auth'); - const tCommon = useTranslations('common'); const { user, isAuthReady } = useAuth(); return ( <div className="min-h-[100dvh] flex flex-col app-web-bg text-text-primary"> <header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10"> <div className="container mx-auto px-4 py-3 sm:py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3"> - <Link href="/" className="text-xl sm:text-2xl font-semibold text-text-primary shrink-0"> - {tCommon('appName')} + <Link href="/" className="shrink-0"> + <BrandLockup className="text-xl sm:text-2xl text-text-primary" /> </Link> <div className="flex items-center gap-1.5 sm:gap-3 ml-auto shrink-0"> diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 36ec6ae..fc49a8d 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -9,7 +9,9 @@ import { Link } from '@/i18n/navigation'; import { Mail, Lock, User, Phone } from 'lucide-react'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; +import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; +import { BrandLockup } from '@/components/ui/shared/BrandLockup'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { Button } from '@/components/ui/shared/Button'; @@ -38,9 +40,10 @@ export default function RegisterPage() { const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); - const { registerTrial, isLoading } = useAuth(); + const { registerTrial, isLoading, isAuthReady } = useAuth(); const [step, setStep] = useState(1); const [error, setError] = useState<string | null>(null); + useEnterAppWhenAuthenticated(); const registerSchema = useMemo( () => @@ -121,14 +124,20 @@ export default function RegisterPage() { hide: t('hidePassword'), }; + if (!isAuthReady) { + return ( + <div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4"> + <p className="text-text-secondary">{tCommon('loading')}</p> + </div> + ); + } + return ( <AuthPageShell header={ <> <Link href="/" className="flex justify-center"> - <span className="text-2xl sm:text-3xl font-semibold text-text-primary"> - {tCommon('appName')} - </span> + <BrandLockup className="text-2xl sm:text-3xl text-text-primary" /> </Link> <h2 className="mt-4 sm:mt-6 text-center text-2xl sm:text-3xl font-semibold text-text-primary"> {t('registerTitle')} diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx index 5fffee5..9969ef5 100644 --- a/frontend/src/app/[locale]/layout.tsx +++ b/frontend/src/app/[locale]/layout.tsx @@ -13,7 +13,7 @@ import { routing, isRtlLocale, localeHtmlLang } from '@/i18n/routing'; import { LocaleSync } from '@/components/i18n/LocaleSync'; export const metadata: Metadata = { - title: 'DyoLink - Dental Clinic & Lab Communication Hub', + title: 'Nudentic - Dental Clinic & Lab Communication Hub', description: 'Connect dental clinics and laboratories seamlessly', }; diff --git a/frontend/src/app/icon.svg b/frontend/src/app/icon.svg new file mode 100644 index 0000000..6f51109 --- /dev/null +++ b/frontend/src/app/icon.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4294 3630"> + <g id="N" fill="#0f172a"> + <path d="M 606.00 77.00 C 577.20 78.12 548.38 82.04 519.78 86.05 C 491.18 90.07 463.16 97.62 435.75 106.85 C 408.33 116.08 381.85 127.66 356.34 141.29 C 330.83 154.93 306.17 170.35 283.64 188.40 C 261.11 206.44 240.97 227.44 222.07 249.32 C 203.16 271.20 186.56 294.89 171.64 319.68 C 156.72 344.46 143.35 370.12 132.40 396.87 C 121.45 423.63 111.96 450.94 103.38 478.53 C 94.80 506.11 86.61 533.89 80.45 562.10 C 74.29 590.32 69.03 618.77 63.85 647.18 C 58.66 675.60 56.18 704.43 52.75 733.12 C 49.31 761.81 46.91 790.59 45.04 819.43 C 43.16 848.27 41.70 877.12 41.32 906.01 C 40.94 934.90 39.79 963.79 40.04 992.67 C 40.29 1021.56 41.40 1050.44 41.83 1079.33 C 42.26 1108.22 42.27 1137.13 43.89 1165.97 C 45.50 1194.81 47.35 1223.65 48.83 1252.51 C 50.32 1281.36 52.06 1310.19 54.25 1339.00 C 56.44 1367.81 58.22 1396.65 60.92 1425.42 C 63.62 1454.18 66.11 1482.97 68.27 1511.78 C 70.42 1540.59 73.37 1569.33 76.16 1598.09 C 78.95 1626.84 81.84 1655.59 85.46 1684.25 C 89.07 1712.91 93.33 1741.50 97.02 1770.15 C 100.72 1798.81 103.58 1827.57 107.94 1856.13 C 112.31 1884.69 116.41 1913.29 120.10 1941.94 C 123.79 1970.60 127.59 1999.25 132.14 2027.77 C 136.69 2056.30 142.26 2084.67 146.62 2113.23 C 150.98 2141.79 156.51 2170.15 161.87 2198.54 C 167.22 2226.93 171.79 2255.46 177.38 2283.81 C 182.96 2312.15 188.66 2340.48 194.55 2368.76 C 200.44 2397.04 206.76 2425.23 212.99 2453.45 C 219.22 2481.66 225.23 2509.92 231.99 2538.01 C 238.75 2566.10 246.53 2593.94 253.84 2621.89 C 261.16 2649.83 268.07 2677.89 275.76 2705.74 C 283.46 2733.59 291.85 2761.24 300.25 2788.88 C 308.65 2816.52 317.06 2844.16 325.77 2871.71 C 334.48 2899.26 344.70 2926.29 354.54 2953.46 C 364.38 2980.63 374.21 3007.79 384.90 3034.63 C 395.59 3061.47 406.51 3088.24 417.83 3114.81 C 429.16 3141.39 442.17 3167.20 454.57 3193.31 C 466.97 3219.42 480.99 3244.65 495.57 3269.62 C 510.14 3294.58 524.54 3319.66 541.73 3342.92 C 558.92 3366.18 576.88 3388.78 596.93 3409.70 C 616.98 3430.61 639.19 3449.56 664.40 3463.72 C 689.61 3477.88 718.29 3487.15 747.33 3486.94 C 776.37 3486.73 804.75 3476.44 829.07 3460.67 C 853.39 3444.91 873.80 3423.71 890.76 3400.26 C 907.72 3376.82 921.72 3351.40 934.43 3325.45 C 947.14 3299.51 957.51 3272.48 967.42 3245.34 C 977.33 3218.19 985.53 3190.49 994.01 3162.86 C 1002.48 3135.24 1010.77 3107.57 1018.67 3079.78 C 1026.58 3051.99 1036.65 3024.85 1044.02 2996.91 C 1051.38 2968.97 1059.80 2941.34 1068.33 2913.73 C 1076.85 2886.12 1085.53 2858.57 1094.23 2831.02 C 1102.94 2803.47 1110.56 2775.58 1120.71 2748.52 C 1130.86 2721.47 1140.10 2694.08 1150.29 2667.05 C 1160.48 2640.02 1171.47 2613.28 1182.61 2586.63 C 1193.76 2559.98 1205.43 2533.54 1217.90 2507.47 C 1230.37 2481.40 1243.49 2455.68 1256.68 2429.96 C 1269.87 2404.24 1285.22 2379.61 1301.22 2355.67 C 1317.22 2331.74 1334.29 2307.43 1351.88 2284.62 C 1369.46 2261.81 1381.86 2242.88 1412.32 2216.41 C 1442.79 2189.95 1414.19 2187.72 1387.33 2199.64 C 1360.47 2211.56 1337.37 2225.42 1312.38 2240.36 C 1287.39 2255.30 1264.14 2272.22 1241.08 2289.63 C 1218.02 2307.05 1196.10 2325.95 1175.20 2345.91 C 1154.29 2365.86 1134.41 2386.88 1115.68 2408.87 C 1096.94 2430.87 1079.15 2453.66 1061.65 2476.65 C 1044.16 2499.64 1027.90 2523.57 1012.67 2548.12 C 997.45 2572.67 981.98 2597.10 967.39 2622.03 C 952.80 2646.95 939.90 2672.85 926.73 2698.56 C 913.56 2724.28 900.54 2750.07 888.37 2776.28 C 876.20 2802.48 864.76 2829.02 853.73 2855.73 C 842.71 2882.43 831.89 2909.24 821.89 2936.34 C 811.90 2963.44 803.13 2990.99 793.83 3018.44 C 784.52 3045.89 784.10 3074.54 762.83 3099.76 C 741.55 3124.98 712.92 3114.90 699.17 3084.43 C 685.43 3053.96 675.29 3031.40 664.44 3004.36 C 653.58 2977.32 645.70 2949.49 636.12 2922.25 C 626.53 2895.01 616.30 2867.91 608.07 2840.23 C 599.84 2812.54 592.94 2784.46 585.28 2756.60 C 577.62 2728.75 569.96 2700.88 563.70 2672.67 C 557.44 2644.47 551.76 2616.14 545.47 2587.94 C 539.17 2559.74 533.31 2531.45 528.24 2503.01 C 523.16 2474.56 517.21 2446.28 512.79 2417.73 C 508.38 2389.18 503.32 2360.73 498.63 2332.22 C 493.95 2303.71 489.78 2275.12 485.37 2246.57 C 480.96 2218.02 477.15 2189.37 473.88 2160.67 C 470.62 2131.96 467.15 2103.28 464.10 2074.55 C 461.05 2045.82 458.35 2017.05 455.17 1988.34 C 451.99 1959.62 449.74 1930.81 446.62 1902.09 C 443.49 1873.36 440.81 1844.61 438.47 1815.81 C 436.14 1787.01 433.12 1758.27 430.81 1729.47 C 428.50 1700.68 427.76 1671.77 425.61 1642.96 C 423.47 1614.14 421.44 1585.33 420.55 1556.45 C 419.65 1527.57 417.34 1498.76 416.25 1469.89 C 415.16 1441.02 414.03 1412.15 413.13 1383.27 C 412.24 1354.39 412.02 1325.50 411.46 1296.61 C 410.89 1267.73 410.75 1238.84 411.07 1209.94 C 411.39 1181.05 411.98 1152.16 412.68 1123.29 C 413.39 1094.42 414.27 1065.55 415.60 1036.67 C 416.93 1007.79 418.81 978.93 422.27 950.26 C 425.73 921.60 430.29 893.02 434.75 864.47 C 439.21 835.92 447.11 808.09 455.01 780.27 C 462.91 752.44 472.64 725.17 486.01 699.50 C 499.39 673.82 515.38 649.12 536.81 629.57 C 558.23 610.02 584.99 596.75 613.22 590.01 C 641.45 583.26 670.97 584.00 699.38 589.84 C 727.78 595.68 755.10 605.59 781.06 618.31 C 807.02 631.02 831.61 646.57 854.86 663.68 C 878.11 680.79 900.72 698.87 922.47 717.89 C 944.23 736.91 964.61 757.39 984.76 778.12 C 1004.90 798.84 1025.36 819.28 1044.47 840.93 C 1063.58 862.58 1081.94 884.93 1100.64 906.95 C 1119.34 928.97 1137.45 951.48 1155.23 974.26 C 1173.01 997.03 1190.58 1019.97 1208.08 1042.96 C 1225.57 1065.95 1242.94 1089.03 1260.18 1112.22 C 1277.41 1135.41 1294.52 1158.69 1311.14 1182.32 C 1327.76 1205.95 1344.72 1229.34 1361.38 1252.95 C 1378.04 1276.56 1394.45 1300.34 1411.34 1323.77 C 1428.24 1347.20 1443.96 1371.46 1460.26 1395.31 C 1476.56 1419.16 1492.78 1443.07 1508.93 1467.03 C 1525.08 1490.99 1541.26 1514.92 1556.91 1539.21 C 1572.55 1563.50 1588.14 1587.82 1604.41 1611.71 C 1620.67 1635.59 1636.32 1659.87 1651.66 1684.36 C 1666.99 1708.84 1682.76 1733.05 1698.90 1757.02 C 1715.04 1780.98 1731.11 1805.00 1746.31 1829.56 C 1761.52 1854.13 1777.81 1878.00 1793.49 1902.26 C 1809.17 1926.52 1823.91 1951.39 1839.47 1975.74 C 1855.02 2000.08 1870.83 2024.26 1886.46 2048.56 C 1902.08 2072.86 1917.19 2097.49 1932.93 2121.73 C 1948.66 2145.96 1964.62 2170.04 1979.71 2194.68 C 1994.81 2219.32 2009.85 2243.99 2026.12 2267.87 C 2042.39 2291.75 2057.97 2316.08 2073.24 2340.61 C 2088.51 2365.13 2104.50 2389.22 2120.23 2413.45 C 2135.95 2437.68 2150.28 2462.72 2166.51 2486.73 C 2182.74 2510.73 2198.79 2534.57 2214.28 2558.88 C 2229.77 2583.20 2245.46 2608.73 2261.19 2632.26 C 2276.91 2655.80 2292.59 2683.06 2311.28 2708.24 C 2329.96 2733.43 2345.56 2689.45 2361.98 2663.94 C 2378.39 2638.43 2395.02 2618.79 2411.42 2594.50 C 2427.81 2570.20 2444.35 2547.05 2460.87 2523.33 C 2477.39 2499.60 2494.25 2475.59 2509.21 2451.76 C 2524.18 2427.93 2556.50 2404.45 2559.77 2380.05 C 2563.04 2355.64 2530.11 2330.14 2516.76 2305.23 C 2503.42 2280.31 2487.44 2255.98 2472.73 2231.09 C 2458.02 2206.19 2442.94 2181.64 2427.96 2156.93 C 2412.97 2132.22 2398.66 2107.12 2383.80 2082.35 C 2368.94 2057.57 2353.85 2032.93 2338.71 2008.32 C 2323.58 1983.72 2308.40 1959.13 2293.41 1934.44 C 2278.41 1909.74 2262.93 1885.35 2247.53 1860.90 C 2232.12 1836.46 2218.15 1811.15 2202.67 1786.75 C 2187.19 1762.36 2172.32 1737.58 2156.95 1713.12 C 2141.58 1688.66 2126.15 1664.23 2111.07 1639.59 C 2096.00 1614.94 2080.32 1590.67 2065.17 1566.07 C 2050.03 1541.47 2034.94 1516.82 2018.97 1492.74 C 2003.00 1468.66 1988.05 1443.94 1973.23 1419.14 C 1958.41 1394.34 1941.62 1370.77 1926.18 1346.36 C 1910.73 1321.95 1896.56 1296.72 1880.49 1272.71 C 1864.43 1248.70 1848.39 1224.67 1833.01 1200.21 C 1817.62 1175.76 1801.55 1151.74 1785.97 1127.41 C 1770.39 1103.09 1753.85 1079.38 1738.31 1055.03 C 1722.77 1030.67 1707.50 1006.14 1691.33 982.20 C 1675.15 958.26 1658.42 934.70 1642.07 910.88 C 1625.73 887.05 1610.19 862.69 1593.92 838.82 C 1577.65 814.95 1560.81 791.46 1544.37 767.70 C 1527.93 743.94 1511.56 720.14 1494.26 697.00 C 1476.96 673.86 1460.94 649.76 1443.24 626.94 C 1425.54 604.11 1407.32 581.67 1389.68 558.79 C 1372.04 535.90 1354.22 513.16 1335.45 491.20 C 1316.68 469.24 1297.45 447.66 1277.94 426.36 C 1258.42 405.05 1238.69 383.91 1218.10 363.65 C 1197.51 343.39 1175.53 324.58 1153.94 305.39 C 1132.35 286.19 1110.24 267.54 1086.97 250.41 C 1063.70 233.29 1039.69 217.16 1015.20 201.83 C 990.71 186.50 965.20 172.88 939.63 159.40 C 914.06 145.91 887.41 134.74 860.34 124.58 C 833.27 114.42 805.94 104.88 777.83 98.17 C 749.72 91.45 721.17 87.11 692.44 83.02 C 663.71 78.93 634.80 75.89 606.00 77.00 Z"/> + </g> + <g id="U" fill="#00B4C8"> + <path d="M 3520.00 40.00 C 3495.68 40.87 3471.41 43.19 3447.16 45.36 C 3422.92 47.53 3398.76 50.41 3374.60 53.50 C 3350.45 56.58 3326.32 59.97 3302.39 64.37 C 3278.45 68.77 3254.67 74.05 3230.98 79.70 C 3207.30 85.35 3183.99 92.34 3160.61 99.17 C 3137.24 106.00 3113.87 112.89 3090.87 120.81 C 3067.86 128.74 3045.21 137.72 3022.63 146.83 C 3000.05 155.93 2977.44 164.93 2955.05 174.49 C 2932.65 184.04 2910.78 194.74 2888.82 205.25 C 2866.86 215.76 2844.90 226.29 2823.52 237.93 C 2802.13 249.57 2781.21 262.03 2760.07 274.10 C 2738.93 286.17 2717.85 298.35 2697.06 311.01 C 2676.26 323.67 2656.00 337.17 2635.86 350.84 C 2615.71 364.51 2595.45 378.01 2575.88 392.49 C 2556.31 406.97 2536.51 421.16 2517.42 436.26 C 2498.34 451.37 2479.35 466.61 2460.05 481.45 C 2440.75 496.28 2421.72 511.47 2403.19 527.26 C 2384.65 543.04 2366.82 559.71 2348.71 575.88 C 2330.59 592.05 2311.72 608.01 2294.49 625.14 C 2277.26 642.27 2263.34 660.13 2239.28 675.15 C 2215.22 690.17 2221.78 721.33 2246.32 701.26 C 2270.85 681.19 2290.92 673.96 2312.44 662.62 C 2333.96 651.28 2355.77 639.35 2377.52 628.60 C 2399.28 617.84 2421.14 606.90 2443.00 596.21 C 2464.86 585.51 2487.05 575.51 2509.27 565.56 C 2531.49 555.60 2553.54 545.27 2576.02 535.93 C 2598.49 526.58 2620.92 517.09 2643.47 507.93 C 2666.02 498.77 2689.01 490.71 2711.62 481.70 C 2734.24 472.70 2756.85 463.60 2780.03 456.16 C 2803.21 448.73 2826.17 440.62 2849.28 432.98 C 2872.39 425.34 2895.90 418.93 2919.36 412.45 C 2942.82 405.96 2966.36 399.74 2990.02 394.02 C 3013.69 388.30 3037.48 383.16 3061.37 378.48 C 3085.26 373.81 3109.21 369.31 3133.28 365.68 C 3157.34 362.05 3181.62 359.96 3205.79 357.02 C 3229.96 354.07 3254.25 352.48 3278.60 352.00 C 3302.96 351.52 3327.32 351.65 3351.60 353.38 C 3375.87 355.11 3400.09 357.97 3424.22 361.26 C 3448.36 364.56 3472.10 369.90 3495.52 376.67 C 3518.93 383.44 3542.21 390.65 3564.46 400.58 C 3586.71 410.51 3608.43 421.65 3628.90 434.89 C 3649.38 448.14 3667.66 464.31 3684.77 481.71 C 3701.89 499.11 3718.09 517.41 3730.66 538.30 C 3743.22 559.19 3754.52 580.84 3764.26 603.15 C 3774.01 625.46 3781.97 648.53 3788.69 671.93 C 3795.40 695.33 3801.07 719.05 3805.96 742.90 C 3810.85 766.74 3814.53 790.81 3817.62 814.96 C 3820.72 839.11 3823.71 863.30 3825.60 887.56 C 3827.49 911.82 3827.92 936.19 3828.99 960.51 C 3830.06 984.83 3831.78 1009.13 3832.11 1033.47 C 3832.43 1057.80 3831.85 1082.16 3831.92 1106.50 C 3832.00 1130.84 3832.59 1155.20 3831.54 1179.52 C 3830.49 1203.84 3829.34 1228.16 3829.06 1252.50 C 3828.78 1276.85 3828.38 1301.19 3826.61 1325.48 C 3824.84 1349.76 3824.13 1374.10 3822.79 1398.40 C 3821.45 1422.71 3819.41 1446.97 3817.85 1471.27 C 3816.28 1495.56 3814.93 1519.87 3812.80 1544.12 C 3810.66 1568.37 3808.33 1592.60 3806.19 1616.85 C 3804.05 1641.10 3801.92 1665.35 3799.57 1689.58 C 3797.22 1713.81 3794.58 1738.01 3791.89 1762.20 C 3789.20 1786.40 3786.77 1810.62 3783.80 1834.78 C 3780.83 1858.94 3777.11 1883.01 3774.49 1907.21 C 3771.87 1931.42 3768.47 1955.52 3765.13 1979.64 C 3761.78 2003.75 3758.36 2027.85 3754.47 2051.88 C 3750.58 2075.91 3746.55 2099.92 3742.77 2123.97 C 3738.99 2148.02 3735.28 2172.08 3731.04 2196.05 C 3726.80 2220.02 3722.11 2243.91 3717.66 2267.84 C 3713.21 2291.78 3709.05 2315.76 3703.97 2339.57 C 3698.90 2363.38 3693.76 2387.18 3688.81 2411.01 C 3683.87 2434.85 3678.65 2458.63 3672.95 2482.30 C 3667.26 2505.97 3661.40 2529.59 3655.41 2553.19 C 3649.42 2576.78 3643.39 2600.37 3636.76 2623.80 C 3630.14 2647.22 3622.96 2670.48 3615.58 2693.68 C 3608.21 2716.88 3600.66 2740.03 3592.72 2763.04 C 3584.78 2786.05 3576.45 2808.92 3567.50 2831.57 C 3558.56 2854.21 3549.03 2876.63 3538.91 2898.78 C 3528.79 2920.92 3517.41 2942.41 3505.38 2963.60 C 3493.36 2984.78 3482.57 3006.78 3467.77 3026.08 C 3452.97 3045.38 3437.91 3064.96 3420.33 3081.67 C 3402.75 3098.37 3382.03 3111.99 3360.61 3123.65 C 3339.18 3135.30 3315.86 3143.09 3291.79 3147.02 C 3267.72 3150.96 3243.06 3151.24 3218.86 3148.24 C 3194.67 3145.24 3170.99 3138.81 3148.40 3129.70 C 3125.82 3120.59 3104.12 3109.19 3083.85 3095.68 C 3063.57 3082.17 3045.20 3066.07 3027.43 3049.42 C 3009.67 3032.76 2992.10 3015.86 2975.82 2997.75 C 2959.53 2979.65 2945.46 2959.73 2930.72 2940.35 C 2915.98 2920.97 2901.64 2901.31 2887.69 2881.35 C 2873.75 2861.39 2859.85 2841.41 2846.59 2821.00 C 2833.32 2800.58 2818.79 2781.02 2805.49 2760.64 C 2792.19 2740.25 2779.03 2719.76 2765.99 2699.20 C 2752.96 2678.65 2740.37 2657.80 2726.74 2637.63 C 2713.11 2617.46 2700.17 2596.83 2686.84 2576.46 C 2673.51 2556.09 2661.15 2535.08 2648.56 2514.28 C 2635.96 2493.48 2621.58 2473.55 2608.88 2452.79 C 2596.19 2432.04 2588.64 2405.52 2565.99 2392.97 C 2543.34 2380.42 2535.13 2420.68 2521.31 2440.84 C 2507.48 2461.00 2492.74 2478.80 2479.16 2499.28 C 2465.57 2519.76 2452.06 2539.84 2438.15 2559.73 C 2424.25 2579.62 2409.67 2599.28 2395.52 2619.15 C 2381.37 2639.03 2371.07 2660.24 2354.59 2679.62 C 2338.11 2699.00 2325.52 2720.92 2335.99 2744.55 C 2346.46 2768.19 2362.54 2785.58 2375.71 2806.12 C 2388.88 2826.66 2403.00 2846.52 2416.46 2866.80 C 2429.91 2887.09 2444.08 2906.89 2458.20 2926.72 C 2472.31 2946.55 2485.17 2967.25 2499.51 2986.93 C 2513.85 3006.61 2527.59 3026.70 2541.94 3046.37 C 2556.28 3066.04 2570.60 3085.73 2585.03 3105.33 C 2599.45 3124.94 2614.56 3144.03 2629.08 3163.58 C 2643.60 3183.13 2658.60 3202.30 2674.08 3221.07 C 2689.57 3239.85 2705.21 3258.53 2720.98 3277.07 C 2736.76 3295.62 2752.92 3313.82 2769.81 3331.35 C 2786.70 3348.87 2803.56 3366.49 2821.20 3383.25 C 2838.85 3400.00 2857.46 3415.73 2876.32 3431.15 C 2895.17 3446.57 2914.54 3461.34 2934.81 3474.82 C 2955.07 3488.31 2975.87 3501.06 2997.25 3512.69 C 3018.63 3524.31 3040.64 3534.85 3063.09 3544.28 C 3085.53 3553.70 3108.94 3560.64 3132.31 3567.42 C 3155.69 3574.19 3179.31 3580.66 3203.37 3584.15 C 3227.44 3587.65 3251.87 3588.56 3276.23 3588.97 C 3300.59 3589.37 3324.95 3588.48 3349.09 3585.32 C 3373.24 3582.15 3397.18 3577.50 3420.87 3571.89 C 3444.57 3566.27 3467.91 3559.05 3490.35 3549.64 C 3512.79 3540.22 3534.82 3529.63 3555.97 3517.57 C 3577.12 3505.51 3597.06 3491.49 3616.31 3476.55 C 3635.57 3461.61 3654.18 3445.88 3671.46 3428.72 C 3688.73 3411.57 3704.98 3393.39 3720.41 3374.55 C 3735.84 3355.70 3750.43 3336.20 3764.34 3316.23 C 3778.25 3296.26 3791.51 3275.82 3804.15 3255.01 C 3816.78 3234.19 3828.68 3212.93 3839.56 3191.16 C 3850.44 3169.38 3860.60 3147.24 3870.71 3125.10 C 3880.82 3102.96 3891.12 3080.89 3900.85 3058.57 C 3910.57 3036.26 3918.21 3013.10 3927.31 2990.52 C 3936.41 2967.94 3944.87 2945.12 3952.80 2922.10 C 3960.73 2899.08 3968.29 2875.94 3975.64 2852.74 C 3982.99 2829.53 3991.08 2806.55 3998.21 2783.28 C 4005.35 2760.01 4011.90 2736.56 4018.54 2713.14 C 4025.17 2689.72 4031.83 2666.30 4038.19 2642.80 C 4044.56 2619.31 4050.52 2595.71 4056.18 2572.03 C 4061.84 2548.35 4067.89 2524.77 4073.32 2501.04 C 4078.76 2477.31 4084.46 2453.64 4089.91 2429.92 C 4095.36 2406.20 4100.08 2382.30 4105.22 2358.51 C 4110.37 2334.72 4114.06 2310.63 4119.04 2286.81 C 4124.02 2262.98 4129.22 2239.19 4133.32 2215.19 C 4137.43 2191.20 4141.21 2167.15 4145.63 2143.21 C 4150.05 2119.27 4154.12 2095.26 4157.40 2071.14 C 4160.67 2047.02 4164.34 2022.95 4168.14 1998.91 C 4171.95 1974.87 4175.03 1950.72 4178.19 1926.58 C 4181.35 1902.44 4184.84 1878.34 4188.21 1854.24 C 4191.59 1830.13 4194.53 1805.96 4197.19 1781.77 C 4199.85 1757.57 4202.63 1733.38 4205.44 1709.20 C 4208.25 1685.02 4210.01 1660.73 4212.44 1636.51 C 4214.87 1612.29 4218.37 1588.18 4220.92 1563.97 C 4223.46 1539.76 4224.06 1515.39 4226.32 1491.15 C 4228.59 1466.91 4231.02 1442.68 4232.84 1418.41 C 4234.66 1394.14 4236.25 1369.85 4237.88 1345.56 C 4239.51 1321.27 4241.10 1296.98 4242.56 1272.68 C 4244.01 1248.38 4245.35 1224.07 4246.62 1199.76 C 4247.89 1175.45 4248.97 1151.13 4249.42 1126.79 C 4249.86 1102.45 4251.08 1078.13 4252.02 1053.80 C 4252.97 1029.48 4253.21 1005.14 4253.09 980.80 C 4252.96 956.45 4252.46 932.11 4251.90 907.77 C 4251.33 883.43 4250.63 859.10 4249.49 834.79 C 4248.36 810.47 4246.73 786.18 4244.95 761.90 C 4243.17 737.61 4241.65 713.30 4238.86 689.13 C 4236.07 664.95 4232.24 640.88 4228.43 616.84 C 4224.61 592.79 4220.07 568.88 4214.91 545.09 C 4209.75 521.29 4204.46 497.48 4197.34 474.21 C 4190.22 450.94 4181.64 428.12 4172.97 405.37 C 4164.31 382.62 4154.70 360.18 4142.91 338.88 C 4131.11 317.58 4118.76 296.57 4105.02 276.46 C 4091.28 256.34 4075.80 237.43 4058.87 219.94 C 4041.94 202.45 4023.73 186.16 4004.67 170.98 C 3985.62 155.80 3965.17 142.54 3943.87 130.71 C 3922.58 118.87 3900.62 108.19 3878.07 99.03 C 3855.53 89.87 3832.37 82.32 3809.01 75.39 C 3785.66 68.45 3762.03 62.42 3738.11 57.95 C 3714.19 53.48 3690.00 50.47 3665.78 47.96 C 3641.56 45.45 3617.33 43.28 3593.01 41.66 C 3568.70 40.04 3544.32 39.12 3520.00 40.00 Z"/> + </g> +</svg> diff --git a/frontend/src/assets/brand/logo.svg b/frontend/src/assets/brand/logo.svg new file mode 100644 index 0000000..2792f9c --- /dev/null +++ b/frontend/src/assets/brand/logo.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4294 3630"> + <g id="N" fill="currentColor"> + <path d="M 606.00 77.00 C 577.20 78.12 548.38 82.04 519.78 86.05 C 491.18 90.07 463.16 97.62 435.75 106.85 C 408.33 116.08 381.85 127.66 356.34 141.29 C 330.83 154.93 306.17 170.35 283.64 188.40 C 261.11 206.44 240.97 227.44 222.07 249.32 C 203.16 271.20 186.56 294.89 171.64 319.68 C 156.72 344.46 143.35 370.12 132.40 396.87 C 121.45 423.63 111.96 450.94 103.38 478.53 C 94.80 506.11 86.61 533.89 80.45 562.10 C 74.29 590.32 69.03 618.77 63.85 647.18 C 58.66 675.60 56.18 704.43 52.75 733.12 C 49.31 761.81 46.91 790.59 45.04 819.43 C 43.16 848.27 41.70 877.12 41.32 906.01 C 40.94 934.90 39.79 963.79 40.04 992.67 C 40.29 1021.56 41.40 1050.44 41.83 1079.33 C 42.26 1108.22 42.27 1137.13 43.89 1165.97 C 45.50 1194.81 47.35 1223.65 48.83 1252.51 C 50.32 1281.36 52.06 1310.19 54.25 1339.00 C 56.44 1367.81 58.22 1396.65 60.92 1425.42 C 63.62 1454.18 66.11 1482.97 68.27 1511.78 C 70.42 1540.59 73.37 1569.33 76.16 1598.09 C 78.95 1626.84 81.84 1655.59 85.46 1684.25 C 89.07 1712.91 93.33 1741.50 97.02 1770.15 C 100.72 1798.81 103.58 1827.57 107.94 1856.13 C 112.31 1884.69 116.41 1913.29 120.10 1941.94 C 123.79 1970.60 127.59 1999.25 132.14 2027.77 C 136.69 2056.30 142.26 2084.67 146.62 2113.23 C 150.98 2141.79 156.51 2170.15 161.87 2198.54 C 167.22 2226.93 171.79 2255.46 177.38 2283.81 C 182.96 2312.15 188.66 2340.48 194.55 2368.76 C 200.44 2397.04 206.76 2425.23 212.99 2453.45 C 219.22 2481.66 225.23 2509.92 231.99 2538.01 C 238.75 2566.10 246.53 2593.94 253.84 2621.89 C 261.16 2649.83 268.07 2677.89 275.76 2705.74 C 283.46 2733.59 291.85 2761.24 300.25 2788.88 C 308.65 2816.52 317.06 2844.16 325.77 2871.71 C 334.48 2899.26 344.70 2926.29 354.54 2953.46 C 364.38 2980.63 374.21 3007.79 384.90 3034.63 C 395.59 3061.47 406.51 3088.24 417.83 3114.81 C 429.16 3141.39 442.17 3167.20 454.57 3193.31 C 466.97 3219.42 480.99 3244.65 495.57 3269.62 C 510.14 3294.58 524.54 3319.66 541.73 3342.92 C 558.92 3366.18 576.88 3388.78 596.93 3409.70 C 616.98 3430.61 639.19 3449.56 664.40 3463.72 C 689.61 3477.88 718.29 3487.15 747.33 3486.94 C 776.37 3486.73 804.75 3476.44 829.07 3460.67 C 853.39 3444.91 873.80 3423.71 890.76 3400.26 C 907.72 3376.82 921.72 3351.40 934.43 3325.45 C 947.14 3299.51 957.51 3272.48 967.42 3245.34 C 977.33 3218.19 985.53 3190.49 994.01 3162.86 C 1002.48 3135.24 1010.77 3107.57 1018.67 3079.78 C 1026.58 3051.99 1036.65 3024.85 1044.02 2996.91 C 1051.38 2968.97 1059.80 2941.34 1068.33 2913.73 C 1076.85 2886.12 1085.53 2858.57 1094.23 2831.02 C 1102.94 2803.47 1110.56 2775.58 1120.71 2748.52 C 1130.86 2721.47 1140.10 2694.08 1150.29 2667.05 C 1160.48 2640.02 1171.47 2613.28 1182.61 2586.63 C 1193.76 2559.98 1205.43 2533.54 1217.90 2507.47 C 1230.37 2481.40 1243.49 2455.68 1256.68 2429.96 C 1269.87 2404.24 1285.22 2379.61 1301.22 2355.67 C 1317.22 2331.74 1334.29 2307.43 1351.88 2284.62 C 1369.46 2261.81 1381.86 2242.88 1412.32 2216.41 C 1442.79 2189.95 1414.19 2187.72 1387.33 2199.64 C 1360.47 2211.56 1337.37 2225.42 1312.38 2240.36 C 1287.39 2255.30 1264.14 2272.22 1241.08 2289.63 C 1218.02 2307.05 1196.10 2325.95 1175.20 2345.91 C 1154.29 2365.86 1134.41 2386.88 1115.68 2408.87 C 1096.94 2430.87 1079.15 2453.66 1061.65 2476.65 C 1044.16 2499.64 1027.90 2523.57 1012.67 2548.12 C 997.45 2572.67 981.98 2597.10 967.39 2622.03 C 952.80 2646.95 939.90 2672.85 926.73 2698.56 C 913.56 2724.28 900.54 2750.07 888.37 2776.28 C 876.20 2802.48 864.76 2829.02 853.73 2855.73 C 842.71 2882.43 831.89 2909.24 821.89 2936.34 C 811.90 2963.44 803.13 2990.99 793.83 3018.44 C 784.52 3045.89 784.10 3074.54 762.83 3099.76 C 741.55 3124.98 712.92 3114.90 699.17 3084.43 C 685.43 3053.96 675.29 3031.40 664.44 3004.36 C 653.58 2977.32 645.70 2949.49 636.12 2922.25 C 626.53 2895.01 616.30 2867.91 608.07 2840.23 C 599.84 2812.54 592.94 2784.46 585.28 2756.60 C 577.62 2728.75 569.96 2700.88 563.70 2672.67 C 557.44 2644.47 551.76 2616.14 545.47 2587.94 C 539.17 2559.74 533.31 2531.45 528.24 2503.01 C 523.16 2474.56 517.21 2446.28 512.79 2417.73 C 508.38 2389.18 503.32 2360.73 498.63 2332.22 C 493.95 2303.71 489.78 2275.12 485.37 2246.57 C 480.96 2218.02 477.15 2189.37 473.88 2160.67 C 470.62 2131.96 467.15 2103.28 464.10 2074.55 C 461.05 2045.82 458.35 2017.05 455.17 1988.34 C 451.99 1959.62 449.74 1930.81 446.62 1902.09 C 443.49 1873.36 440.81 1844.61 438.47 1815.81 C 436.14 1787.01 433.12 1758.27 430.81 1729.47 C 428.50 1700.68 427.76 1671.77 425.61 1642.96 C 423.47 1614.14 421.44 1585.33 420.55 1556.45 C 419.65 1527.57 417.34 1498.76 416.25 1469.89 C 415.16 1441.02 414.03 1412.15 413.13 1383.27 C 412.24 1354.39 412.02 1325.50 411.46 1296.61 C 410.89 1267.73 410.75 1238.84 411.07 1209.94 C 411.39 1181.05 411.98 1152.16 412.68 1123.29 C 413.39 1094.42 414.27 1065.55 415.60 1036.67 C 416.93 1007.79 418.81 978.93 422.27 950.26 C 425.73 921.60 430.29 893.02 434.75 864.47 C 439.21 835.92 447.11 808.09 455.01 780.27 C 462.91 752.44 472.64 725.17 486.01 699.50 C 499.39 673.82 515.38 649.12 536.81 629.57 C 558.23 610.02 584.99 596.75 613.22 590.01 C 641.45 583.26 670.97 584.00 699.38 589.84 C 727.78 595.68 755.10 605.59 781.06 618.31 C 807.02 631.02 831.61 646.57 854.86 663.68 C 878.11 680.79 900.72 698.87 922.47 717.89 C 944.23 736.91 964.61 757.39 984.76 778.12 C 1004.90 798.84 1025.36 819.28 1044.47 840.93 C 1063.58 862.58 1081.94 884.93 1100.64 906.95 C 1119.34 928.97 1137.45 951.48 1155.23 974.26 C 1173.01 997.03 1190.58 1019.97 1208.08 1042.96 C 1225.57 1065.95 1242.94 1089.03 1260.18 1112.22 C 1277.41 1135.41 1294.52 1158.69 1311.14 1182.32 C 1327.76 1205.95 1344.72 1229.34 1361.38 1252.95 C 1378.04 1276.56 1394.45 1300.34 1411.34 1323.77 C 1428.24 1347.20 1443.96 1371.46 1460.26 1395.31 C 1476.56 1419.16 1492.78 1443.07 1508.93 1467.03 C 1525.08 1490.99 1541.26 1514.92 1556.91 1539.21 C 1572.55 1563.50 1588.14 1587.82 1604.41 1611.71 C 1620.67 1635.59 1636.32 1659.87 1651.66 1684.36 C 1666.99 1708.84 1682.76 1733.05 1698.90 1757.02 C 1715.04 1780.98 1731.11 1805.00 1746.31 1829.56 C 1761.52 1854.13 1777.81 1878.00 1793.49 1902.26 C 1809.17 1926.52 1823.91 1951.39 1839.47 1975.74 C 1855.02 2000.08 1870.83 2024.26 1886.46 2048.56 C 1902.08 2072.86 1917.19 2097.49 1932.93 2121.73 C 1948.66 2145.96 1964.62 2170.04 1979.71 2194.68 C 1994.81 2219.32 2009.85 2243.99 2026.12 2267.87 C 2042.39 2291.75 2057.97 2316.08 2073.24 2340.61 C 2088.51 2365.13 2104.50 2389.22 2120.23 2413.45 C 2135.95 2437.68 2150.28 2462.72 2166.51 2486.73 C 2182.74 2510.73 2198.79 2534.57 2214.28 2558.88 C 2229.77 2583.20 2245.46 2608.73 2261.19 2632.26 C 2276.91 2655.80 2292.59 2683.06 2311.28 2708.24 C 2329.96 2733.43 2345.56 2689.45 2361.98 2663.94 C 2378.39 2638.43 2395.02 2618.79 2411.42 2594.50 C 2427.81 2570.20 2444.35 2547.05 2460.87 2523.33 C 2477.39 2499.60 2494.25 2475.59 2509.21 2451.76 C 2524.18 2427.93 2556.50 2404.45 2559.77 2380.05 C 2563.04 2355.64 2530.11 2330.14 2516.76 2305.23 C 2503.42 2280.31 2487.44 2255.98 2472.73 2231.09 C 2458.02 2206.19 2442.94 2181.64 2427.96 2156.93 C 2412.97 2132.22 2398.66 2107.12 2383.80 2082.35 C 2368.94 2057.57 2353.85 2032.93 2338.71 2008.32 C 2323.58 1983.72 2308.40 1959.13 2293.41 1934.44 C 2278.41 1909.74 2262.93 1885.35 2247.53 1860.90 C 2232.12 1836.46 2218.15 1811.15 2202.67 1786.75 C 2187.19 1762.36 2172.32 1737.58 2156.95 1713.12 C 2141.58 1688.66 2126.15 1664.23 2111.07 1639.59 C 2096.00 1614.94 2080.32 1590.67 2065.17 1566.07 C 2050.03 1541.47 2034.94 1516.82 2018.97 1492.74 C 2003.00 1468.66 1988.05 1443.94 1973.23 1419.14 C 1958.41 1394.34 1941.62 1370.77 1926.18 1346.36 C 1910.73 1321.95 1896.56 1296.72 1880.49 1272.71 C 1864.43 1248.70 1848.39 1224.67 1833.01 1200.21 C 1817.62 1175.76 1801.55 1151.74 1785.97 1127.41 C 1770.39 1103.09 1753.85 1079.38 1738.31 1055.03 C 1722.77 1030.67 1707.50 1006.14 1691.33 982.20 C 1675.15 958.26 1658.42 934.70 1642.07 910.88 C 1625.73 887.05 1610.19 862.69 1593.92 838.82 C 1577.65 814.95 1560.81 791.46 1544.37 767.70 C 1527.93 743.94 1511.56 720.14 1494.26 697.00 C 1476.96 673.86 1460.94 649.76 1443.24 626.94 C 1425.54 604.11 1407.32 581.67 1389.68 558.79 C 1372.04 535.90 1354.22 513.16 1335.45 491.20 C 1316.68 469.24 1297.45 447.66 1277.94 426.36 C 1258.42 405.05 1238.69 383.91 1218.10 363.65 C 1197.51 343.39 1175.53 324.58 1153.94 305.39 C 1132.35 286.19 1110.24 267.54 1086.97 250.41 C 1063.70 233.29 1039.69 217.16 1015.20 201.83 C 990.71 186.50 965.20 172.88 939.63 159.40 C 914.06 145.91 887.41 134.74 860.34 124.58 C 833.27 114.42 805.94 104.88 777.83 98.17 C 749.72 91.45 721.17 87.11 692.44 83.02 C 663.71 78.93 634.80 75.89 606.00 77.00 Z"/> + </g> + <g id="U" fill="#00B4C8"> + <path d="M 3520.00 40.00 C 3495.68 40.87 3471.41 43.19 3447.16 45.36 C 3422.92 47.53 3398.76 50.41 3374.60 53.50 C 3350.45 56.58 3326.32 59.97 3302.39 64.37 C 3278.45 68.77 3254.67 74.05 3230.98 79.70 C 3207.30 85.35 3183.99 92.34 3160.61 99.17 C 3137.24 106.00 3113.87 112.89 3090.87 120.81 C 3067.86 128.74 3045.21 137.72 3022.63 146.83 C 3000.05 155.93 2977.44 164.93 2955.05 174.49 C 2932.65 184.04 2910.78 194.74 2888.82 205.25 C 2866.86 215.76 2844.90 226.29 2823.52 237.93 C 2802.13 249.57 2781.21 262.03 2760.07 274.10 C 2738.93 286.17 2717.85 298.35 2697.06 311.01 C 2676.26 323.67 2656.00 337.17 2635.86 350.84 C 2615.71 364.51 2595.45 378.01 2575.88 392.49 C 2556.31 406.97 2536.51 421.16 2517.42 436.26 C 2498.34 451.37 2479.35 466.61 2460.05 481.45 C 2440.75 496.28 2421.72 511.47 2403.19 527.26 C 2384.65 543.04 2366.82 559.71 2348.71 575.88 C 2330.59 592.05 2311.72 608.01 2294.49 625.14 C 2277.26 642.27 2263.34 660.13 2239.28 675.15 C 2215.22 690.17 2221.78 721.33 2246.32 701.26 C 2270.85 681.19 2290.92 673.96 2312.44 662.62 C 2333.96 651.28 2355.77 639.35 2377.52 628.60 C 2399.28 617.84 2421.14 606.90 2443.00 596.21 C 2464.86 585.51 2487.05 575.51 2509.27 565.56 C 2531.49 555.60 2553.54 545.27 2576.02 535.93 C 2598.49 526.58 2620.92 517.09 2643.47 507.93 C 2666.02 498.77 2689.01 490.71 2711.62 481.70 C 2734.24 472.70 2756.85 463.60 2780.03 456.16 C 2803.21 448.73 2826.17 440.62 2849.28 432.98 C 2872.39 425.34 2895.90 418.93 2919.36 412.45 C 2942.82 405.96 2966.36 399.74 2990.02 394.02 C 3013.69 388.30 3037.48 383.16 3061.37 378.48 C 3085.26 373.81 3109.21 369.31 3133.28 365.68 C 3157.34 362.05 3181.62 359.96 3205.79 357.02 C 3229.96 354.07 3254.25 352.48 3278.60 352.00 C 3302.96 351.52 3327.32 351.65 3351.60 353.38 C 3375.87 355.11 3400.09 357.97 3424.22 361.26 C 3448.36 364.56 3472.10 369.90 3495.52 376.67 C 3518.93 383.44 3542.21 390.65 3564.46 400.58 C 3586.71 410.51 3608.43 421.65 3628.90 434.89 C 3649.38 448.14 3667.66 464.31 3684.77 481.71 C 3701.89 499.11 3718.09 517.41 3730.66 538.30 C 3743.22 559.19 3754.52 580.84 3764.26 603.15 C 3774.01 625.46 3781.97 648.53 3788.69 671.93 C 3795.40 695.33 3801.07 719.05 3805.96 742.90 C 3810.85 766.74 3814.53 790.81 3817.62 814.96 C 3820.72 839.11 3823.71 863.30 3825.60 887.56 C 3827.49 911.82 3827.92 936.19 3828.99 960.51 C 3830.06 984.83 3831.78 1009.13 3832.11 1033.47 C 3832.43 1057.80 3831.85 1082.16 3831.92 1106.50 C 3832.00 1130.84 3832.59 1155.20 3831.54 1179.52 C 3830.49 1203.84 3829.34 1228.16 3829.06 1252.50 C 3828.78 1276.85 3828.38 1301.19 3826.61 1325.48 C 3824.84 1349.76 3824.13 1374.10 3822.79 1398.40 C 3821.45 1422.71 3819.41 1446.97 3817.85 1471.27 C 3816.28 1495.56 3814.93 1519.87 3812.80 1544.12 C 3810.66 1568.37 3808.33 1592.60 3806.19 1616.85 C 3804.05 1641.10 3801.92 1665.35 3799.57 1689.58 C 3797.22 1713.81 3794.58 1738.01 3791.89 1762.20 C 3789.20 1786.40 3786.77 1810.62 3783.80 1834.78 C 3780.83 1858.94 3777.11 1883.01 3774.49 1907.21 C 3771.87 1931.42 3768.47 1955.52 3765.13 1979.64 C 3761.78 2003.75 3758.36 2027.85 3754.47 2051.88 C 3750.58 2075.91 3746.55 2099.92 3742.77 2123.97 C 3738.99 2148.02 3735.28 2172.08 3731.04 2196.05 C 3726.80 2220.02 3722.11 2243.91 3717.66 2267.84 C 3713.21 2291.78 3709.05 2315.76 3703.97 2339.57 C 3698.90 2363.38 3693.76 2387.18 3688.81 2411.01 C 3683.87 2434.85 3678.65 2458.63 3672.95 2482.30 C 3667.26 2505.97 3661.40 2529.59 3655.41 2553.19 C 3649.42 2576.78 3643.39 2600.37 3636.76 2623.80 C 3630.14 2647.22 3622.96 2670.48 3615.58 2693.68 C 3608.21 2716.88 3600.66 2740.03 3592.72 2763.04 C 3584.78 2786.05 3576.45 2808.92 3567.50 2831.57 C 3558.56 2854.21 3549.03 2876.63 3538.91 2898.78 C 3528.79 2920.92 3517.41 2942.41 3505.38 2963.60 C 3493.36 2984.78 3482.57 3006.78 3467.77 3026.08 C 3452.97 3045.38 3437.91 3064.96 3420.33 3081.67 C 3402.75 3098.37 3382.03 3111.99 3360.61 3123.65 C 3339.18 3135.30 3315.86 3143.09 3291.79 3147.02 C 3267.72 3150.96 3243.06 3151.24 3218.86 3148.24 C 3194.67 3145.24 3170.99 3138.81 3148.40 3129.70 C 3125.82 3120.59 3104.12 3109.19 3083.85 3095.68 C 3063.57 3082.17 3045.20 3066.07 3027.43 3049.42 C 3009.67 3032.76 2992.10 3015.86 2975.82 2997.75 C 2959.53 2979.65 2945.46 2959.73 2930.72 2940.35 C 2915.98 2920.97 2901.64 2901.31 2887.69 2881.35 C 2873.75 2861.39 2859.85 2841.41 2846.59 2821.00 C 2833.32 2800.58 2818.79 2781.02 2805.49 2760.64 C 2792.19 2740.25 2779.03 2719.76 2765.99 2699.20 C 2752.96 2678.65 2740.37 2657.80 2726.74 2637.63 C 2713.11 2617.46 2700.17 2596.83 2686.84 2576.46 C 2673.51 2556.09 2661.15 2535.08 2648.56 2514.28 C 2635.96 2493.48 2621.58 2473.55 2608.88 2452.79 C 2596.19 2432.04 2588.64 2405.52 2565.99 2392.97 C 2543.34 2380.42 2535.13 2420.68 2521.31 2440.84 C 2507.48 2461.00 2492.74 2478.80 2479.16 2499.28 C 2465.57 2519.76 2452.06 2539.84 2438.15 2559.73 C 2424.25 2579.62 2409.67 2599.28 2395.52 2619.15 C 2381.37 2639.03 2371.07 2660.24 2354.59 2679.62 C 2338.11 2699.00 2325.52 2720.92 2335.99 2744.55 C 2346.46 2768.19 2362.54 2785.58 2375.71 2806.12 C 2388.88 2826.66 2403.00 2846.52 2416.46 2866.80 C 2429.91 2887.09 2444.08 2906.89 2458.20 2926.72 C 2472.31 2946.55 2485.17 2967.25 2499.51 2986.93 C 2513.85 3006.61 2527.59 3026.70 2541.94 3046.37 C 2556.28 3066.04 2570.60 3085.73 2585.03 3105.33 C 2599.45 3124.94 2614.56 3144.03 2629.08 3163.58 C 2643.60 3183.13 2658.60 3202.30 2674.08 3221.07 C 2689.57 3239.85 2705.21 3258.53 2720.98 3277.07 C 2736.76 3295.62 2752.92 3313.82 2769.81 3331.35 C 2786.70 3348.87 2803.56 3366.49 2821.20 3383.25 C 2838.85 3400.00 2857.46 3415.73 2876.32 3431.15 C 2895.17 3446.57 2914.54 3461.34 2934.81 3474.82 C 2955.07 3488.31 2975.87 3501.06 2997.25 3512.69 C 3018.63 3524.31 3040.64 3534.85 3063.09 3544.28 C 3085.53 3553.70 3108.94 3560.64 3132.31 3567.42 C 3155.69 3574.19 3179.31 3580.66 3203.37 3584.15 C 3227.44 3587.65 3251.87 3588.56 3276.23 3588.97 C 3300.59 3589.37 3324.95 3588.48 3349.09 3585.32 C 3373.24 3582.15 3397.18 3577.50 3420.87 3571.89 C 3444.57 3566.27 3467.91 3559.05 3490.35 3549.64 C 3512.79 3540.22 3534.82 3529.63 3555.97 3517.57 C 3577.12 3505.51 3597.06 3491.49 3616.31 3476.55 C 3635.57 3461.61 3654.18 3445.88 3671.46 3428.72 C 3688.73 3411.57 3704.98 3393.39 3720.41 3374.55 C 3735.84 3355.70 3750.43 3336.20 3764.34 3316.23 C 3778.25 3296.26 3791.51 3275.82 3804.15 3255.01 C 3816.78 3234.19 3828.68 3212.93 3839.56 3191.16 C 3850.44 3169.38 3860.60 3147.24 3870.71 3125.10 C 3880.82 3102.96 3891.12 3080.89 3900.85 3058.57 C 3910.57 3036.26 3918.21 3013.10 3927.31 2990.52 C 3936.41 2967.94 3944.87 2945.12 3952.80 2922.10 C 3960.73 2899.08 3968.29 2875.94 3975.64 2852.74 C 3982.99 2829.53 3991.08 2806.55 3998.21 2783.28 C 4005.35 2760.01 4011.90 2736.56 4018.54 2713.14 C 4025.17 2689.72 4031.83 2666.30 4038.19 2642.80 C 4044.56 2619.31 4050.52 2595.71 4056.18 2572.03 C 4061.84 2548.35 4067.89 2524.77 4073.32 2501.04 C 4078.76 2477.31 4084.46 2453.64 4089.91 2429.92 C 4095.36 2406.20 4100.08 2382.30 4105.22 2358.51 C 4110.37 2334.72 4114.06 2310.63 4119.04 2286.81 C 4124.02 2262.98 4129.22 2239.19 4133.32 2215.19 C 4137.43 2191.20 4141.21 2167.15 4145.63 2143.21 C 4150.05 2119.27 4154.12 2095.26 4157.40 2071.14 C 4160.67 2047.02 4164.34 2022.95 4168.14 1998.91 C 4171.95 1974.87 4175.03 1950.72 4178.19 1926.58 C 4181.35 1902.44 4184.84 1878.34 4188.21 1854.24 C 4191.59 1830.13 4194.53 1805.96 4197.19 1781.77 C 4199.85 1757.57 4202.63 1733.38 4205.44 1709.20 C 4208.25 1685.02 4210.01 1660.73 4212.44 1636.51 C 4214.87 1612.29 4218.37 1588.18 4220.92 1563.97 C 4223.46 1539.76 4224.06 1515.39 4226.32 1491.15 C 4228.59 1466.91 4231.02 1442.68 4232.84 1418.41 C 4234.66 1394.14 4236.25 1369.85 4237.88 1345.56 C 4239.51 1321.27 4241.10 1296.98 4242.56 1272.68 C 4244.01 1248.38 4245.35 1224.07 4246.62 1199.76 C 4247.89 1175.45 4248.97 1151.13 4249.42 1126.79 C 4249.86 1102.45 4251.08 1078.13 4252.02 1053.80 C 4252.97 1029.48 4253.21 1005.14 4253.09 980.80 C 4252.96 956.45 4252.46 932.11 4251.90 907.77 C 4251.33 883.43 4250.63 859.10 4249.49 834.79 C 4248.36 810.47 4246.73 786.18 4244.95 761.90 C 4243.17 737.61 4241.65 713.30 4238.86 689.13 C 4236.07 664.95 4232.24 640.88 4228.43 616.84 C 4224.61 592.79 4220.07 568.88 4214.91 545.09 C 4209.75 521.29 4204.46 497.48 4197.34 474.21 C 4190.22 450.94 4181.64 428.12 4172.97 405.37 C 4164.31 382.62 4154.70 360.18 4142.91 338.88 C 4131.11 317.58 4118.76 296.57 4105.02 276.46 C 4091.28 256.34 4075.80 237.43 4058.87 219.94 C 4041.94 202.45 4023.73 186.16 4004.67 170.98 C 3985.62 155.80 3965.17 142.54 3943.87 130.71 C 3922.58 118.87 3900.62 108.19 3878.07 99.03 C 3855.53 89.87 3832.37 82.32 3809.01 75.39 C 3785.66 68.45 3762.03 62.42 3738.11 57.95 C 3714.19 53.48 3690.00 50.47 3665.78 47.96 C 3641.56 45.45 3617.33 43.28 3593.01 41.66 C 3568.70 40.04 3544.32 39.12 3520.00 40.00 Z"/> + </g> +</svg> diff --git a/frontend/src/assets/brand/wordmark.svg b/frontend/src/assets/brand/wordmark.svg new file mode 100644 index 0000000..73ae274 --- /dev/null +++ b/frontend/src/assets/brand/wordmark.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 345 70"> + <path d="M 293.500 22.500 L 290.500 22.500 L 290.250 22.750 L 289.250 22.750 L 288.000 23.250 L 287.500 24.000 L 287.500 27.250 L 287.750 27.500 L 287.750 59.750 L 288.000 60.000 L 288.000 61.750 L 287.750 62.500 L 288.500 64.000 L 290.250 64.000 L 290.500 64.250 L 293.750 64.000 L 294.000 63.750 L 294.000 61.000 L 294.250 60.750 L 294.250 23.500 Z M 103.250 22.500 L 102.750 22.250 L 99.750 22.250 L 98.750 22.750 L 97.250 24.250 L 97.250 48.750 L 96.250 50.750 L 96.250 52.250 L 94.250 55.250 L 92.500 57.000 L 91.750 57.250 L 90.750 58.250 L 90.000 58.250 L 88.000 59.250 L 85.750 59.250 L 85.500 59.500 L 80.750 59.250 L 79.500 58.750 L 79.000 58.250 L 78.000 58.000 L 77.500 57.500 L 76.750 57.250 L 75.000 55.500 L 73.750 53.000 L 73.000 52.000 L 73.000 49.750 L 72.250 48.500 L 72.250 42.750 L 72.000 42.500 L 72.000 23.250 L 71.250 22.500 L 68.000 22.500 L 66.500 23.000 L 65.750 24.000 L 65.750 28.000 L 66.000 28.250 L 66.000 49.000 L 66.250 49.250 L 66.250 52.500 L 67.250 54.250 L 67.250 55.500 L 68.250 56.500 L 68.250 57.500 L 70.250 60.000 L 73.750 63.000 L 74.500 63.000 L 75.750 64.000 L 77.250 64.000 L 77.500 64.250 L 79.000 64.500 L 79.750 65.000 L 84.500 65.000 L 87.250 64.000 L 89.000 64.000 L 89.750 63.250 L 91.500 62.750 L 94.250 60.750 L 96.250 58.750 L 96.750 58.750 L 97.250 59.500 L 97.250 63.500 L 97.750 64.000 L 99.250 64.000 L 99.500 64.250 L 103.000 64.000 L 103.750 63.250 L 104.000 62.500 L 104.000 38.500 L 103.750 38.250 L 103.750 36.000 L 104.000 35.750 L 104.000 23.500 Z M 318.500 22.250 L 317.500 23.000 L 315.750 23.250 L 315.000 24.000 L 313.750 24.250 L 312.750 25.250 L 312.000 25.500 L 310.000 27.000 L 308.000 29.000 L 306.500 31.000 L 306.250 31.750 L 305.250 32.750 L 305.250 33.500 L 304.250 34.750 L 304.000 36.500 L 303.250 37.500 L 303.250 40.250 L 302.750 41.750 L 302.750 44.000 L 303.000 44.250 L 303.000 46.000 L 303.250 46.250 L 303.250 49.000 L 304.250 50.500 L 304.250 52.000 L 305.000 52.750 L 305.500 54.250 L 306.250 55.000 L 306.250 55.500 L 307.250 56.750 L 307.750 57.000 L 310.750 60.250 L 312.500 61.250 L 313.250 62.000 L 313.750 62.000 L 314.750 63.000 L 315.750 63.000 L 317.250 64.000 L 319.250 64.000 L 322.000 65.000 L 326.500 65.000 L 329.250 64.000 L 331.000 64.000 L 332.500 63.000 L 333.500 63.000 L 334.500 62.000 L 335.250 62.000 L 336.000 61.250 L 336.750 61.000 L 340.000 57.750 L 340.000 56.500 L 338.500 55.000 L 338.250 55.000 L 337.750 54.250 L 336.000 54.250 L 332.500 57.250 L 331.750 57.250 L 330.750 58.250 L 329.750 58.250 L 327.500 59.250 L 321.000 59.250 L 319.750 58.750 L 319.250 58.250 L 318.500 58.250 L 316.250 57.000 L 314.500 55.250 L 314.250 55.250 L 312.000 52.750 L 311.000 51.000 L 310.750 50.000 L 310.000 49.000 L 310.000 47.000 L 309.500 46.500 L 309.500 46.000 L 309.000 45.250 L 309.250 41.000 L 309.500 40.750 L 309.500 40.000 L 310.000 39.250 L 310.000 37.500 L 310.750 36.500 L 311.000 35.500 L 312.250 33.250 L 316.500 29.250 L 317.750 29.000 L 319.000 28.000 L 320.500 28.000 L 321.750 27.250 L 326.250 27.250 L 327.750 28.000 L 329.250 28.000 L 330.500 29.000 L 331.750 29.250 L 335.000 32.000 L 336.500 32.000 L 340.000 29.000 L 340.000 28.500 L 339.250 28.000 L 336.750 25.250 L 335.750 25.000 L 333.750 23.500 L 331.750 23.000 L 330.750 22.250 L 328.250 22.250 L 327.250 21.750 L 322.000 21.750 L 320.500 22.250 Z M 242.500 24.250 L 241.750 24.250 L 240.750 23.250 L 239.750 23.250 L 238.250 22.250 L 236.250 22.250 L 235.500 21.750 L 234.750 21.750 L 234.500 21.500 L 231.500 21.500 L 231.250 21.750 L 230.250 21.750 L 229.000 22.250 L 227.000 22.250 L 225.500 23.250 L 224.500 23.250 L 223.500 24.250 L 223.000 24.250 L 221.500 25.250 L 219.250 27.750 L 218.500 28.000 L 218.000 27.250 L 218.000 23.000 L 217.750 22.500 L 217.000 22.250 L 214.000 22.250 L 213.750 22.500 L 212.750 22.500 L 211.750 23.500 L 211.750 33.750 L 211.500 34.000 L 211.500 39.250 L 211.750 39.500 L 211.750 62.750 L 212.250 64.000 L 217.750 64.000 L 218.250 62.750 L 218.250 38.500 L 219.000 37.250 L 219.000 35.250 L 220.000 34.000 L 220.000 33.500 L 221.000 31.750 L 222.000 31.000 L 222.500 30.250 L 223.250 30.000 L 224.500 29.000 L 225.000 29.000 L 226.250 28.000 L 227.500 28.000 L 229.000 27.250 L 233.250 27.250 L 234.500 28.000 L 235.750 28.000 L 236.750 28.750 L 237.750 29.000 L 238.250 29.750 L 241.250 32.500 L 241.250 33.250 L 242.250 34.500 L 242.250 35.750 L 243.250 38.250 L 243.250 63.500 L 243.750 64.000 L 245.250 64.000 L 245.500 64.250 L 249.000 64.000 L 249.750 62.750 L 249.750 37.750 L 249.000 36.000 L 249.000 33.250 L 248.000 31.750 L 247.750 30.500 L 246.000 28.250 L 246.000 27.750 L 245.000 26.500 Z M 182.000 21.500 L 179.750 22.250 L 177.500 22.250 L 176.500 23.000 L 174.750 23.250 L 174.000 24.000 L 172.750 24.250 L 170.250 26.250 L 167.500 29.000 L 167.000 30.000 L 166.250 30.500 L 166.000 31.250 L 165.250 32.000 L 165.250 32.750 L 164.250 33.750 L 164.000 35.500 L 163.250 36.500 L 163.250 39.000 L 162.250 41.750 L 162.250 44.000 L 162.500 44.250 L 162.500 45.500 L 162.750 45.750 L 163.000 47.750 L 163.250 48.000 L 163.250 50.000 L 164.250 51.500 L 164.250 52.500 L 165.250 53.750 L 165.250 54.500 L 166.250 55.500 L 168.000 58.000 L 170.000 60.000 L 172.000 61.250 L 172.500 62.000 L 173.000 62.000 L 174.500 63.000 L 175.500 63.000 L 177.000 64.000 L 179.000 64.000 L 181.750 65.000 L 186.500 65.000 L 189.250 64.000 L 191.250 64.000 L 192.500 63.000 L 193.750 63.000 L 194.750 62.250 L 195.750 62.000 L 197.750 60.250 L 198.500 60.000 L 201.000 57.250 L 201.000 56.500 L 200.250 56.000 L 199.750 55.250 L 199.000 55.000 L 198.500 54.500 L 197.250 54.000 L 196.500 54.000 L 191.500 58.000 L 189.250 58.500 L 188.000 59.250 L 184.500 59.250 L 184.250 59.500 L 183.500 59.250 L 180.250 59.250 L 179.000 58.250 L 177.750 58.250 L 176.750 57.250 L 176.250 57.250 L 175.000 56.500 L 171.250 52.750 L 171.000 51.500 L 170.000 50.500 L 170.000 49.000 L 169.500 48.500 L 169.500 48.000 L 169.000 47.250 L 169.000 46.000 L 169.750 45.250 L 202.500 45.250 L 203.500 44.750 L 203.750 41.500 L 203.500 41.250 L 203.500 39.750 L 203.000 38.250 L 203.000 35.750 L 202.000 34.250 L 202.000 33.500 L 201.250 32.500 L 201.000 31.500 L 199.000 29.500 L 199.000 29.000 L 197.500 27.250 L 193.750 24.250 L 192.000 23.250 L 190.500 23.000 L 189.500 22.250 L 187.750 22.250 L 185.500 21.500 Z M 169.250 38.250 L 170.000 37.250 L 170.000 36.250 L 170.750 35.250 L 171.750 33.000 L 175.000 29.750 L 175.750 29.250 L 177.000 29.000 L 178.250 28.000 L 179.500 28.000 L 181.250 27.000 L 185.500 27.000 L 186.750 27.500 L 187.250 28.000 L 188.500 28.000 L 189.250 28.750 L 190.750 29.250 L 194.250 32.500 L 196.250 36.500 L 196.500 39.250 L 195.750 40.000 L 169.750 40.000 L 169.250 39.500 Z M 260.500 11.250 L 260.250 11.500 L 260.250 21.250 L 259.500 22.250 L 258.750 22.500 L 256.750 22.250 L 256.250 22.750 L 256.000 23.500 L 256.000 27.000 L 256.500 28.000 L 259.250 28.000 L 259.750 28.250 L 260.250 29.000 L 260.250 55.250 L 261.250 57.250 L 261.250 58.500 L 262.250 59.500 L 262.500 60.250 L 264.250 62.000 L 267.250 64.000 L 269.500 64.250 L 271.000 65.000 L 274.250 65.000 L 276.250 64.000 L 278.000 64.000 L 280.000 62.750 L 280.000 61.000 L 279.250 60.250 L 279.000 59.250 L 278.250 58.500 L 277.500 58.500 L 276.750 59.250 L 271.250 59.250 L 270.000 58.500 L 268.000 56.750 L 267.000 55.000 L 267.000 53.250 L 266.750 53.000 L 266.750 28.500 L 267.500 28.000 L 275.750 28.000 L 277.000 26.750 L 278.000 24.750 L 277.500 23.500 L 276.750 22.750 L 275.500 22.250 L 268.500 22.250 L 267.750 22.500 L 267.000 21.750 L 267.000 16.000 L 266.750 15.750 L 266.750 14.000 L 266.500 13.750 L 266.250 11.750 L 265.750 11.250 Z M 3.750 5.250 L 3.250 5.750 L 3.250 60.500 L 3.750 61.000 L 4.250 62.500 L 5.500 64.000 L 9.750 64.000 L 10.000 63.750 L 10.000 15.500 L 10.250 15.250 L 10.750 15.250 L 11.250 15.750 L 12.250 17.500 L 13.250 18.750 L 14.250 19.500 L 16.250 22.000 L 16.250 22.500 L 18.250 24.500 L 18.250 24.750 L 19.000 25.250 L 20.250 27.500 L 21.250 28.750 L 23.000 30.250 L 25.000 33.250 L 26.250 34.750 L 27.250 35.500 L 27.500 36.250 L 28.250 37.000 L 29.250 38.750 L 29.750 39.000 L 32.250 41.750 L 33.000 43.250 L 34.250 44.750 L 35.250 45.500 L 37.000 48.250 L 38.250 49.750 L 39.250 50.500 L 41.000 52.750 L 41.250 53.500 L 45.250 58.000 L 45.250 58.500 L 46.250 59.750 L 48.000 61.250 L 49.250 63.250 L 50.000 64.000 L 55.250 64.000 L 56.000 63.000 L 56.000 7.500 L 55.250 6.750 L 54.750 5.500 L 53.750 5.000 L 50.500 5.000 L 49.250 5.500 L 49.250 50.000 L 48.500 50.500 L 47.250 48.750 L 45.750 47.250 L 44.250 45.250 L 44.000 44.500 L 43.500 44.250 L 42.000 42.500 L 40.000 39.500 L 39.500 39.250 L 37.000 36.500 L 36.000 34.750 L 33.250 31.750 L 31.250 29.250 L 31.000 28.500 L 30.500 28.250 L 27.250 24.250 L 27.000 23.500 L 24.750 21.250 L 23.250 19.000 L 20.750 16.250 L 19.250 14.000 L 16.250 10.500 L 16.000 9.750 L 15.000 8.500 L 14.500 8.250 L 14.250 7.750 L 11.750 5.500 L 11.750 5.250 L 11.000 5.250 L 10.750 5.000 L 4.500 5.000 Z M 153.250 4.750 L 150.500 4.750 L 150.250 4.500 L 148.500 5.250 L 148.000 6.250 L 148.000 18.500 L 147.750 18.750 L 147.750 19.500 L 148.000 19.750 L 148.000 20.500 L 147.750 20.750 L 148.000 21.000 L 147.750 21.500 L 147.750 26.750 L 148.000 27.000 L 148.000 28.500 L 147.750 28.750 L 146.750 28.500 L 142.750 24.250 L 142.250 24.250 L 140.750 23.250 L 139.750 23.250 L 138.500 22.250 L 136.250 22.250 L 134.750 21.750 L 130.000 21.750 L 129.000 22.250 L 126.750 22.250 L 125.000 23.250 L 124.000 23.250 L 122.750 24.250 L 120.750 25.250 L 117.250 28.500 L 116.250 29.750 L 116.000 30.500 L 115.250 31.250 L 115.250 31.750 L 114.250 32.750 L 114.250 33.750 L 113.250 35.250 L 113.000 37.500 L 112.250 39.000 L 112.250 47.750 L 113.250 50.000 L 113.250 51.500 L 114.000 52.500 L 114.250 53.750 L 115.000 54.500 L 115.250 55.500 L 116.250 56.500 L 117.500 58.500 L 121.500 62.000 L 122.750 62.500 L 123.250 63.000 L 124.000 63.000 L 125.500 64.000 L 128.500 64.250 L 130.250 65.000 L 134.500 65.000 L 137.250 64.000 L 139.000 64.000 L 139.750 63.250 L 141.500 62.750 L 145.250 60.000 L 146.500 58.250 L 147.250 58.250 L 147.750 58.750 L 147.500 59.250 L 147.500 61.500 L 147.750 61.750 L 148.000 63.500 L 148.500 64.000 L 153.000 64.250 L 154.000 63.750 L 154.250 63.250 L 154.250 59.750 L 154.500 59.500 L 154.500 45.750 L 154.250 45.500 L 154.250 37.500 L 154.500 37.250 L 154.500 12.500 L 154.250 12.250 L 154.250 5.500 Z M 131.750 27.250 L 134.500 27.250 L 134.750 27.500 L 135.500 27.500 L 136.250 28.000 L 138.000 28.000 L 139.000 28.750 L 140.500 29.250 L 142.750 31.000 L 145.250 33.750 L 145.500 34.750 L 146.250 35.500 L 146.250 36.000 L 147.250 37.750 L 147.250 39.250 L 148.000 40.750 L 148.000 45.500 L 147.250 47.000 L 147.250 49.250 L 146.250 51.000 L 146.250 51.750 L 145.250 52.750 L 145.250 53.250 L 144.500 54.250 L 142.500 56.250 L 141.500 57.000 L 140.500 57.250 L 139.750 58.250 L 138.500 58.250 L 137.500 59.000 L 136.750 59.000 L 136.500 59.250 L 130.000 59.250 L 128.750 58.750 L 128.250 58.250 L 127.500 58.250 L 125.250 57.000 L 125.000 56.500 L 121.000 52.750 L 121.000 52.000 L 120.000 50.750 L 120.000 49.250 L 119.250 48.500 L 119.000 47.750 L 119.000 39.250 L 120.000 37.500 L 120.250 35.750 L 121.000 35.000 L 121.000 34.500 L 122.000 33.250 L 122.000 32.750 L 124.750 30.000 L 125.250 30.000 L 126.000 29.250 L 127.000 29.000 L 128.500 28.000 L 130.250 28.000 Z" fill="currentColor" fill-rule="evenodd"/> + <path d="M 290.000 5.000 L 289.250 5.250 L 288.000 6.250 L 286.250 8.250 L 286.000 9.000 L 286.000 10.500 L 286.250 10.750 L 286.250 11.750 L 288.250 14.000 L 288.750 14.250 L 289.750 14.250 L 290.500 14.750 L 291.500 14.750 L 292.750 14.000 L 293.500 14.000 L 295.000 12.250 L 295.250 10.750 L 295.500 10.500 L 295.500 9.500 L 295.000 8.500 L 295.250 7.750 L 292.750 5.250 Z" fill="#18C8E8" fill-rule="evenodd"/> +</svg> diff --git a/frontend/src/assets/fdi/FDI_11.svg b/frontend/src/assets/fdi/FDI_11.svg new file mode 100644 index 0000000..a5d3918 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_11.svg @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" + viewBox="51.660933 83.146350 38.944857 87.264388" + role="img" + aria-labelledby="title-11 desc-11" + data-fdi="11"> + <title id="title-11">FDI 11 maxillary central incisor + Buccal-view tooth asset with independently selectable crown and root groups. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_12.svg b/frontend/src/assets/fdi/FDI_12.svg new file mode 100644 index 0000000..f554838 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_12.svg @@ -0,0 +1,33 @@ + + + FDI 12 + Selectable crown and root SVG tooth asset. + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_13.svg b/frontend/src/assets/fdi/FDI_13.svg new file mode 100644 index 0000000..d75f0fc --- /dev/null +++ b/frontend/src/assets/fdi/FDI_13.svg @@ -0,0 +1,39 @@ + + + FDI 13 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_14.svg b/frontend/src/assets/fdi/FDI_14.svg new file mode 100644 index 0000000..320c39f --- /dev/null +++ b/frontend/src/assets/fdi/FDI_14.svg @@ -0,0 +1,39 @@ + + + FDI 14 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_15.svg b/frontend/src/assets/fdi/FDI_15.svg new file mode 100644 index 0000000..dc8f1a6 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_15.svg @@ -0,0 +1,39 @@ + + + FDI 15 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_16.svg b/frontend/src/assets/fdi/FDI_16.svg new file mode 100644 index 0000000..ad6312c --- /dev/null +++ b/frontend/src/assets/fdi/FDI_16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_17.svg b/frontend/src/assets/fdi/FDI_17.svg new file mode 100644 index 0000000..542cf47 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_17.svg @@ -0,0 +1,27 @@ + + + FDI 17 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_18.svg b/frontend/src/assets/fdi/FDI_18.svg new file mode 100644 index 0000000..b34bd4f --- /dev/null +++ b/frontend/src/assets/fdi/FDI_18.svg @@ -0,0 +1,27 @@ + + + FDI 18 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_21.svg b/frontend/src/assets/fdi/FDI_21.svg new file mode 100644 index 0000000..dbaf83b --- /dev/null +++ b/frontend/src/assets/fdi/FDI_21.svg @@ -0,0 +1,39 @@ + + + FDI 21 maxillary central incisor + Buccal-view tooth asset with independently selectable crown and root groups. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_22.svg b/frontend/src/assets/fdi/FDI_22.svg new file mode 100644 index 0000000..ab560b8 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_22.svg @@ -0,0 +1,33 @@ + + + FDI 22 + Selectable crown and root SVG tooth asset. + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_23.svg b/frontend/src/assets/fdi/FDI_23.svg new file mode 100644 index 0000000..fd37ff7 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_23.svg @@ -0,0 +1,39 @@ + + + FDI 23 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_24.svg b/frontend/src/assets/fdi/FDI_24.svg new file mode 100644 index 0000000..f526384 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_24.svg @@ -0,0 +1,39 @@ + + + FDI 24 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_25.svg b/frontend/src/assets/fdi/FDI_25.svg new file mode 100644 index 0000000..99a9294 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_25.svg @@ -0,0 +1,39 @@ + + + FDI 25 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/src/assets/fdi/FDI_26.svg b/frontend/src/assets/fdi/FDI_26.svg new file mode 100644 index 0000000..37f1315 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_26.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_27.svg b/frontend/src/assets/fdi/FDI_27.svg new file mode 100644 index 0000000..e6c173e --- /dev/null +++ b/frontend/src/assets/fdi/FDI_27.svg @@ -0,0 +1,27 @@ + + + FDI 27 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_28.svg b/frontend/src/assets/fdi/FDI_28.svg new file mode 100644 index 0000000..94de056 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_28.svg @@ -0,0 +1,27 @@ + + + FDI 28 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_31.svg b/frontend/src/assets/fdi/FDI_31.svg new file mode 100644 index 0000000..083a845 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_31.svg @@ -0,0 +1,27 @@ + + + FDI 31 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_32.svg b/frontend/src/assets/fdi/FDI_32.svg new file mode 100644 index 0000000..842d975 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_32.svg @@ -0,0 +1,27 @@ + + + FDI 32 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_33.svg b/frontend/src/assets/fdi/FDI_33.svg new file mode 100644 index 0000000..657e30e --- /dev/null +++ b/frontend/src/assets/fdi/FDI_33.svg @@ -0,0 +1,27 @@ + + + FDI 33 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_34.svg b/frontend/src/assets/fdi/FDI_34.svg new file mode 100644 index 0000000..e52f6c6 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_34.svg @@ -0,0 +1,27 @@ + + + FDI 34 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_35.svg b/frontend/src/assets/fdi/FDI_35.svg new file mode 100644 index 0000000..07b5992 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_35.svg @@ -0,0 +1,27 @@ + + + FDI 35 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_36.svg b/frontend/src/assets/fdi/FDI_36.svg new file mode 100644 index 0000000..7eb4fc4 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_36.svg @@ -0,0 +1,27 @@ + + + FDI 36 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_37.svg b/frontend/src/assets/fdi/FDI_37.svg new file mode 100644 index 0000000..c36e27a --- /dev/null +++ b/frontend/src/assets/fdi/FDI_37.svg @@ -0,0 +1,27 @@ + + + FDI 37 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_38.svg b/frontend/src/assets/fdi/FDI_38.svg new file mode 100644 index 0000000..5288756 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_38.svg @@ -0,0 +1,27 @@ + + + FDI 38 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_41.svg b/frontend/src/assets/fdi/FDI_41.svg new file mode 100644 index 0000000..8c87e2f --- /dev/null +++ b/frontend/src/assets/fdi/FDI_41.svg @@ -0,0 +1,27 @@ + + + FDI 41 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_42.svg b/frontend/src/assets/fdi/FDI_42.svg new file mode 100644 index 0000000..48f7d1c --- /dev/null +++ b/frontend/src/assets/fdi/FDI_42.svg @@ -0,0 +1,27 @@ + + + FDI 42 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_43.svg b/frontend/src/assets/fdi/FDI_43.svg new file mode 100644 index 0000000..e83c361 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_43.svg @@ -0,0 +1,27 @@ + + + FDI 43 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_44.svg b/frontend/src/assets/fdi/FDI_44.svg new file mode 100644 index 0000000..0fcea54 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_44.svg @@ -0,0 +1,27 @@ + + + FDI 44 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_45.svg b/frontend/src/assets/fdi/FDI_45.svg new file mode 100644 index 0000000..86ed8f0 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_45.svg @@ -0,0 +1,27 @@ + + + FDI 45 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_46.svg b/frontend/src/assets/fdi/FDI_46.svg new file mode 100644 index 0000000..1c7d968 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_46.svg @@ -0,0 +1,27 @@ + + + FDI 46 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_47.svg b/frontend/src/assets/fdi/FDI_47.svg new file mode 100644 index 0000000..721db18 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_47.svg @@ -0,0 +1,27 @@ + + + FDI 47 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/fdi/FDI_48.svg b/frontend/src/assets/fdi/FDI_48.svg new file mode 100644 index 0000000..59c4e66 --- /dev/null +++ b/frontend/src/assets/fdi/FDI_48.svg @@ -0,0 +1,27 @@ + + + FDI 48 + Selectable crown and root SVG tooth asset. + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/lab/labCaseOrigin.ts b/frontend/src/components/lab/labCaseOrigin.ts new file mode 100644 index 0000000..0fe2a6c --- /dev/null +++ b/frontend/src/components/lab/labCaseOrigin.ts @@ -0,0 +1,5 @@ +export type LabCaseOriginCode = 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; + +export function isLabGeneratedCase(origin?: string | null): boolean { + return origin === 'LAB_INTERNAL'; +} diff --git a/frontend/src/components/lab/parseTasksSearchParams.ts b/frontend/src/components/lab/parseTasksSearchParams.ts index 4858519..4ab6df1 100644 --- a/frontend/src/components/lab/parseTasksSearchParams.ts +++ b/frontend/src/components/lab/parseTasksSearchParams.ts @@ -16,6 +16,10 @@ export function parseTasksSearchParams( if (searchParams.get('unassignedOnly') === '1') { partial.unassignedOnly = true; } + const origin = searchParams.get('origin'); + if (origin === 'CLINIC_DISPATCH' || origin === 'LAB_INTERNAL') { + partial.origin = origin; + } const status = searchParams.get('status'); if (status === 'IN_PROGRESS' || status === 'COMPLETED') { diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts index 95710f2..4308451 100644 --- a/frontend/src/components/lab/taskListGrouping.ts +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -17,6 +17,7 @@ export type CaseTaskGroup = { caseDueDate: string | null; isCaseOverdue: boolean; isImportant: boolean; + origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; prosthesisGroups: ProsthesisTaskGroup[]; }; @@ -52,6 +53,7 @@ export function groupTasksForDisplay( caseDueDate: task.caseDueDate ?? null, isCaseOverdue: task.isCaseOverdue, isImportant: task.isImportant, + origin: task.origin, prosthesisGroups: [], }); } diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index 8350846..7f34766 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -11,6 +11,7 @@ export type TasksViewState = { assignedToMe: boolean; overdueOnly: boolean; unassignedOnly: boolean; + origin: '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; prosthesisTypeCode: string; page: number; highlightTaskId: string | null; @@ -27,6 +28,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { assignedToMe: false, overdueOnly: false, unassignedOnly: false, + origin: '', prosthesisTypeCode: '', page: 1, highlightTaskId: null, @@ -46,6 +48,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly && state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly && + state.origin === DEFAULT_TASKS_VIEW.origin && state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId diff --git a/frontend/src/components/treatment/dayStrip.ts b/frontend/src/components/treatment/dayStrip.ts new file mode 100644 index 0000000..41707a2 --- /dev/null +++ b/frontend/src/components/treatment/dayStrip.ts @@ -0,0 +1,14 @@ +export type DayStripItemKind = 'appointment' | 'unscheduled'; + +export type DayStripItem = { + kind: DayStripItemKind; + id: string; + patientId: string; + patientFirstName: string; + patientLastName: string; + patientIsWalkIn: boolean; + colorCode: string; + timeLabel: string | null; + subtitle: string; + canDelete?: boolean; +}; diff --git a/frontend/src/components/treatment/detailChipLabel.ts b/frontend/src/components/treatment/detailChipLabel.ts new file mode 100644 index 0000000..37ce2ad --- /dev/null +++ b/frontend/src/components/treatment/detailChipLabel.ts @@ -0,0 +1,15 @@ +import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; + +export function formatDetailChipLabel( + detail: { treatmentType: string; teeth: readonly string[] }, + catalog: TreatmentCatalogEntry[], + fallback: string, +): string { + if (!isDetailTypeSelected(detail)) return fallback; + const typeLabel = treatmentTypeLabelFromCatalog(detail.treatmentType, catalog); + if (detail.teeth.length === 0) return typeLabel; + const teeth = [...detail.teeth].sort().join(', '); + return `${typeLabel} ${teeth}`; +} diff --git a/frontend/src/components/treatment/fdiToothScale.ts b/frontend/src/components/treatment/fdiToothScale.ts new file mode 100644 index 0000000..d2d94f6 --- /dev/null +++ b/frontend/src/components/treatment/fdiToothScale.ts @@ -0,0 +1,75 @@ +import type { FdiToothId } from '@/types/treatment'; + +/** + * Mean mesiodistal crown widths (mm) — buccal chart relative scale. + * + * Anchored on classic Wheeler's Dental Anatomy values (widely used teaching + * standard). Population studies vary slightly, but ratios are stable: + * maxillary central ≈ 1.3× lateral (8.5 / 6.5). + * + * Sources: Wheeler's Dental Anatomy; consistent with published odontometric + * means (e.g. maxillary CI ~8.5–9.2, LI ~6.5–7.4, C ~7.5–8.0, P ~7.0–7.4, + * M1 ~10.0–10.5 mm). + */ +export const FDI_MESIODISTAL_MM: Record = { + // Maxillary + '11': 8.5, + '21': 8.5, + '12': 6.5, + '22': 6.5, + '13': 7.5, + '23': 7.5, + '14': 7.0, + '24': 7.0, + '15': 7.0, + '25': 7.0, + '16': 10.0, + '26': 10.0, + '17': 9.0, + '27': 9.0, + '18': 8.5, + '28': 8.5, + // Mandibular + '41': 5.0, + '31': 5.0, + '42': 5.5, + '32': 5.5, + '43': 7.0, + '33': 7.0, + '44': 7.0, + '34': 7.0, + '45': 7.0, + '35': 7.0, + '46': 11.0, + '36': 11.0, + '47': 10.5, + '37': 10.5, + '48': 10.0, + '38': 10.0, +}; + +/** Reference tooth for relative scale (maxillary lateral). */ +export const FDI_SCALE_REFERENCE: FdiToothId = '12'; + +/** CSS rem per mm of mesiodistal crown width (overall chart size). */ +export const TOOTH_REM_PER_MM = 0.34; + +/** Extra column padding beyond glyph width (keep small so teeth sit close). */ +export const TOOTH_COLUMN_PAD_REM = 0.04; + +export function getToothMesiodistalMm(fdi: FdiToothId): number { + return FDI_MESIODISTAL_MM[fdi]; +} + +/** Width relative to maxillary lateral (12). e.g. 11 → ~1.31 */ +export function getToothRelativeWidth(fdi: FdiToothId): number { + return FDI_MESIODISTAL_MM[fdi] / FDI_MESIODISTAL_MM[FDI_SCALE_REFERENCE]; +} + +export function getToothGlyphWidthRem(fdi: FdiToothId): number { + return Number((FDI_MESIODISTAL_MM[fdi] * TOOTH_REM_PER_MM).toFixed(3)); +} + +export function getToothColumnWidthRem(fdi: FdiToothId): number { + return Number((getToothGlyphWidthRem(fdi) + TOOTH_COLUMN_PAD_REM).toFixed(3)); +} diff --git a/frontend/src/components/treatment/labDispatchDefaults.ts b/frontend/src/components/treatment/labDispatchDefaults.ts new file mode 100644 index 0000000..1dc40b0 --- /dev/null +++ b/frontend/src/components/treatment/labDispatchDefaults.ts @@ -0,0 +1,73 @@ +const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.'; + +export type LabDispatchDefaults = { + lastLabId: string | null; + lastProsthesisByLab: Record; +}; + +const EMPTY: LabDispatchDefaults = { + lastLabId: null, + lastProsthesisByLab: {}, +}; + +function storageKey(clinicOrganizationId: string): string { + return `${STORAGE_PREFIX}${clinicOrganizationId}`; +} + +export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults { + if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY; + try { + const raw = window.localStorage.getItem(storageKey(clinicOrganizationId)); + if (!raw) return EMPTY; + const parsed = JSON.parse(raw) as Partial; + return { + lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null, + lastProsthesisByLab: + parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object' + ? parsed.lastProsthesisByLab + : {}, + }; + } catch { + return EMPTY; + } +} + +function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next)); + } catch { + // Ignore quota / private-mode failures. + } +} + +export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void { + if (!clinicOrganizationId || !labId) return; + const current = loadLabDispatchDefaults(clinicOrganizationId); + writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId }); +} + +export function rememberLastProsthesisType( + clinicOrganizationId: string | null | undefined, + labId: string, + prosthesisTypeCode: string, +): void { + if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return; + const current = loadLabDispatchDefaults(clinicOrganizationId); + writeDefaults(clinicOrganizationId, { + ...current, + lastLabId: labId, + lastProsthesisByLab: { + ...current.lastProsthesisByLab, + [labId]: prosthesisTypeCode, + }, + }); +} + +export function lastProsthesisTypeForLab( + clinicOrganizationId: string | null | undefined, + labId: string | null | undefined, +): string | null { + if (!clinicOrganizationId || !labId) return null; + return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null; +} diff --git a/frontend/src/components/treatment/realisticToothAssets.ts b/frontend/src/components/treatment/realisticToothAssets.ts new file mode 100644 index 0000000..95e4bf5 --- /dev/null +++ b/frontend/src/components/treatment/realisticToothAssets.ts @@ -0,0 +1,261 @@ +import type { FdiToothId } from '@/types/treatment'; + +/** + * Realistic buccal-view assets from src/assets/fdi/FDI_*.svg. + * Upper (1x/2x): root-up. Lower (3x/4x): crown-up. + * Left chart side (Q1/Q4) includes horizontal flip via groupTransform. + */ +export interface RealisticToothAsset { + viewBox: string; + /** Applied inside the svg (e.g. Q1/Q4 mirror baked in source files). */ + groupTransform?: string; + crown: string; + roots: string[]; +} + +export const REALISTIC_TOOTH_ASSETS: Partial> = { + "11": { + "viewBox": "51.660933 83.146350 38.944857 87.264388", + "groupTransform": "translate(142.266723 0) scale(-1 1)", + "crown": "m 62.647814,135.73384 c 0.341028,-0.92433 0.636863,-1.87523 1.110468,-2.73917 0.55297,-1.00871 1.16079,-2.02522 1.99886,-2.8132 0.78325,-0.73643 1.67713,-1.53569 2.73916,-1.70272 1.36726,-0.21503 3.01579,-0.28218 4.51592,-0.14808 0.70385,0.0629 1.41612,0.18341 2.07289,0.4442 0.88501,0.35142 1.77486,0.80219 2.44303,1.48064 0.82263,0.83529 1.25279,1.98653 1.77676,3.03529 0.37075,0.74207 0.69884,1.50842 0.96241,2.29497 0.41266,1.23148 0.63257,2.51941 0.96241,3.77561 0.4804,1.82961 0.9268,3.66959 1.48063,5.47833 0.26732,0.87304 0.62604,1.71654 0.88838,2.5911 0.227,0.75674 0.45137,1.51759 0.59225,2.29498 0.20797,1.14768 0.33655,2.31359 0.37016,3.47948 0.0441,1.53114 0.0671,3.08573 -0.2221,4.58995 -0.30059,1.56338 -0.62675,3.22229 -1.55466,4.51593 -0.39989,0.55751 -0.99264,1.00132 -1.62869,1.25853 -0.82866,0.33509 -1.77525,0.21211 -2.66514,0.29613 -2.21778,0.2094 -4.4354,0.48651 -6.66282,0.51822 -3.01395,0.0429 -6.04047,-0.0736 -9.031859,-0.44426 -1.248744,-0.15472 -2.734205,0.0644 -3.701573,-0.74031 -0.817326,-0.67987 -0.988842,-1.9163 -1.184503,-2.96127 -0.440565,-2.35292 -0.154978,-4.79822 0.07403,-7.18106 0.144009,-1.4984 0.495914,-2.97064 0.814345,-4.44188 0.38742,-1.78999 0.853156,-3.56269 1.332568,-5.33026 0.27596,-1.01745 0.551791,-2.03627 0.888379,-3.03531 0.286792,-0.85123 0.659911,-1.67125 0.96241,-2.51703 0.236518,-0.6613 0.42318,-1.33994 0.666282,-1.99885 z", + "roots": [ + "m 62.943939,135.95593 c 0,0 0.08367,-2.37479 0.222095,-3.55351 0.30378,-2.58681 0.909078,-5.12936 1.332568,-7.69928 0.46704,-2.8342 0.85124,-5.68181 1.33257,-8.51362 0.45826,-2.69605 1.23284,-5.34597 1.48063,-8.06943 0.14534,-1.59742 -0.11495,-3.21216 0,-4.81205 0.16082,-2.23823 0.29166,-4.521436 0.96241,-6.662838 0.47832,-1.527054 1.26926,-2.955761 2.14691,-4.293825 0.65331,-0.99603 1.2756,-2.122935 2.29498,-2.739168 0.49706,-0.300483 1.16538,-0.590669 1.70272,-0.370157 0.62688,0.257255 0.87646,1.066803 1.11047,1.702726 2.1479,5.836971 1.78491,12.315732 2.36901,18.507872 0.53401,5.66112 0.47036,11.36924 1.03644,17.02725 0.31998,3.19822 1.33257,9.55006 1.33257,9.55006 0,0 -1.11923,-3.66392 -2.369,-5.03414 -0.98396,-1.07879 -2.37532,-1.80722 -3.77561,-2.22094 -1.65813,-0.4899 -3.51706,-0.68761 -5.18221,-0.22213 -1.19966,0.33535 -2.2202,1.20048 -3.10933,2.07288 -0.77202,0.7575 -1.85078,2.66514 -1.85078,2.66514 l -1.036442,1.70272" + ] + }, + "12": { + "viewBox": "93.685783 101.132481 28.706127 78.417208", + "groupTransform": "translate(216.077694 0) scale(-1 1)", + "crown": "m 106.17348,144.89797 c 0,0 3.4638,-0.0314 5.03413,0.59222 0.99998,0.3971 1.91519,1.0877 2.59111,1.92482 0.56195,0.69598 0.86436,1.58304 1.11047,2.44304 0.26022,0.90931 0.23314,1.87736 0.37015,2.8132 0.33674,2.30011 0.8184,4.57871 1.11048,6.88492 0.2024,1.59807 0.37007,3.20293 0.44419,4.81206 0.0704,1.52836 0.29878,3.08942 3e-5,4.58995 -0.21387,1.07421 -0.57809,2.17701 -1.25854,3.03529 -0.60414,0.76206 -1.45491,1.37082 -2.369,1.70272 -1.58155,0.57426 -3.35233,0.31948 -5.03415,0.37016 -1.87478,0.0565 -3.76637,0.16741 -5.6264,-0.074 -0.9081,-0.11786 -2.05115,0.0131 -2.665126,-0.66628 -1.15359,-1.27648 -0.68012,-3.38871 -0.74031,-5.10817 -0.0468,-1.3354 0.12,-2.67314 0.29612,-3.9977 0.27493,-2.06772 0.744106,-4.10572 1.184496,-6.14462 0.38036,-1.76099 0.80338,-3.51308 1.25854,-5.25624 0.34341,-1.31517 0.65674,-2.64237 1.11048,-3.92366 0.31368,-0.88578 1.11046,-2.59111 1.11046,-2.59111", + "roots": [ + "m 103.65639,146.97086 c 0.28824,-1.47746 0.58952,-2.95347 0.81432,-4.4419 0.31175,-2.06411 0.4672,-4.14906 0.74031,-6.21864 0.4502,-3.41145 1.03766,-6.80395 1.48063,-10.21635 0.3901,-3.00516 0.78791,-6.01168 1.03644,-9.03185 0.16211,-1.96998 0.0603,-3.95999 0.29612,-5.92252 0.14088,-1.17246 -0.0183,-2.51724 0.66629,-3.47948 0.4294,-0.60357 1.1866,-1.04961 1.92482,-1.11047 0.84051,-0.0693 1.80618,0.26028 2.369,0.88838 0.93463,1.04303 0.78133,2.69464 1.03645,4.07173 0.46167,2.49203 0.74006,5.02113 0.88838,7.55121 0.35959,6.13409 0.0737,12.28968 0,18.43385 -0.037,3.08531 0.15869,6.19199 -0.2221,9.25394 -0.0687,0.55248 0.18433,1.57826 -0.37012,1.62869 -0.18717,0.017 -0.11387,-0.36457 -0.22209,-0.51822 -0.54298,-0.77088 -1.24391,-1.47551 -2.07289,-1.92482 -0.89452,-0.48483 -1.46818,-0.63743 -2.96125,-0.74032 -1.15735,-0.0798 -2.21081,-0.13961 -3.18335,0.29613 -0.89232,0.3998 -1.62374,1.1728 -2.14692,1.99885 -0.30484,0.48131 -0.51825,1.62871 -0.51825,1.62871 0,0 0.30428,-1.42965 0.44421,-2.14692 z" + ] + }, + "13": { + "viewBox": "92.847964 96.106573 35.728809 97.202657", + "groupTransform": "translate(221.424738 0) scale(-1 1)", + "crown": "m 99.551769,176.26584 c -0.0141,-1.55533 0.583831,-3.0579 0.977691,-4.5626 0.27842,-1.06369 0.69205,-2.08858 0.97771,-3.15036 0.33776,-1.25543 0.58103,-2.5344 0.86905,-3.80217 0.25495,-1.12221 0.43819,-2.26287 0.76044,-3.36763 0.26943,-0.92366 0.51784,-1.87069 0.9777,-2.71583 0.45692,-0.83974 1.05465,-1.61288 1.73813,-2.2813 0.70425,-0.68873 1.499,-1.31796 2.38994,-1.73813 1.04856,-0.49451 2.20982,-0.92825 3.36762,-0.86907 1.04125,0.0532 2.04158,0.54576 2.9331,1.08633 0.87688,0.53169 1.66722,1.24275 2.28131,2.06404 0.6498,0.86905 1.01396,1.9237 1.41223,2.93309 0.44557,1.1293 0.73398,2.31451 1.08633,3.47627 0.37246,1.22809 0.74648,2.45602 1.08633,3.69353 0.46516,1.69381 1.12021,3.35885 1.3036,5.10577 0.1854,1.76611 0.31102,3.62761 -0.21726,5.32303 -0.48415,1.55381 -1.62154,2.833 -2.60721,4.12806 -0.65205,0.85672 -1.34836,1.69736 -2.17266,2.38994 -0.9845,0.82718 -2.06044,1.59826 -3.259,2.06402 -0.96001,0.37306 -2.01622,0.63873 -3.04172,0.54317 -1.11828,-0.10421 -2.17801,-0.63288 -3.15037,-1.19496 -1.3367,-0.77271 -2.40515,-1.93711 -3.58491,-2.9331 -1.13747,-0.96029 -2.60004,-1.65763 -3.36762,-2.9331 -0.575191,-0.95578 -0.750311,-2.14353 -0.760431,-3.259 z", + "roots": [ + "m 117.25898,158.99315 c 0.0309,-5.1946 -0.605,-6.99748 -0.54316,-9.23383 0.11896,-4.30188 -0.1936,-7.83304 -0.54316,-11.73239 -0.34249,-3.82049 -0.99928,-7.60631 -1.52087,-11.4065 -0.42268,-3.07959 -1.02386,-6.13798 -1.3036,-9.23383 -0.2446,-2.70697 -0.51897,-5.43636 -0.3259,-8.14749 0.0994,-1.39564 0.99506,-2.74871 0.76043,-4.12807 -0.14687,-0.86342 -0.44168,-2.12583 -1.3036,-2.2813 -1.11331,-0.20081 -2.04194,1.19274 -2.6072,2.17267 -1.70742,2.95995 -1.32174,6.71125 -1.73813,10.1029 -0.83624,6.8115 -0.84156,13.70274 -1.52086,20.53168 -0.78024,7.84376 -1.61721,15.69294 -2.9331,23.46479 -0.0866,0.51119 -0.80686,1.32726 -0.3259,1.52087 0.82965,0.33397 0.90805,-1.54261 1.41223,-2.2813 0.51034,-0.74772 0.95883,-1.56461 1.6295,-2.17267 0.87875,-0.79672 1.90738,-1.50006 3.04173,-1.84676 0.72805,-0.22252 1.52279,-0.17368 2.2813,-0.10864 0.66526,0.057 1.35819,0.13593 1.9554,0.43454 0.89581,0.44791 1.65094,1.1771 2.2813,1.9554 0.57113,0.70517 1.2982,3.29735 1.30359,2.38993 z" + ] + }, + "14": { + "viewBox": "78.778460 104.429020 32.880110 81.849457", + "groupTransform": "translate(190.437029 0) scale(-1 1)", + "crown": "m 103.54613,162.08292 c 0,0 1.51697,3.7883 1.90985,5.77856 0.31375,1.5894 0.86374,3.3141 0.34277,4.84812 -0.68048,2.00371 -2.6583,3.313 -4.21149,4.75018 -1.11188,1.02883 -2.195824,2.2369 -3.623854,2.74237 -1.41645,0.50138 -3.06843,0.58631 -4.50532,0.14691 -2.15763,-0.65981 -3.32378,-1.98281 -5.48474,-3.96664 -1.74427,-1.60129 -3.27631,-3.95329 -3.52591,-6.36622 -0.21622,-2.09029 1.06703,-4.0777 1.8609,-6.02342 1.19972,-2.94044 2.39588,-5.37003 4.3584,-8.47196 0.49409,-0.78095 1.36295,-1.36623 2.25267,-1.61604 1.26081,-0.35399 2.66615,-0.0917 3.91767,0.29382 1.06231,0.32724 2.01728,0.97567 2.88928,1.66501 0.914954,0.7233 1.778634,1.55922 2.399584,2.54649 0.36314,0.57738 0.73456,1.90987 0.73456,1.90987", + "roots": [ + "m 88.707936,158.80187 c 0,0 0.8366,-12.32924 1.56707,-18.46203 0.64849,-5.44453 1.82049,-10.81728 2.49752,-16.25833 0.44545,-3.57992 0.0522,-6.93841 1.02838,-10.7736 0.26861,-1.05527 1.24173,-1.83478 2.10575,-2.49752 0.45775,-0.35111 0.99122,-0.76923 1.56707,-0.73456 0.61487,0.037 1.15915,0.51785 1.56706,0.97942 1.025294,1.16012 2.080354,2.66802 1.958854,4.2115 -0.40846,5.18879 -1.867514,9.88427 -1.909874,14.88715 -0.0826,9.75556 2.747834,21.07548 3.232084,29.08871 0.0654,1.08192 -1.14123,-1.88388 -1.95884,-2.59546 -1.240224,-1.07939 -2.540834,-1.9818 -4.309434,-2.39957 -1.53319,-0.36216 -3.32305,-0.0131 -4.65224,0.8325 -1.14637,0.7293 -2.3506,3.33002 -2.3506,3.33002" + ] + }, + "15": { + "viewBox": "80.851597 94.968970 32.298645 79.507376", + "groupTransform": "translate(194.001840 0) scale(-1 1)", + "crown": "m 86.514392,158.88671 c -0.36012,-1.61219 -0.13293,-3.34785 0.27746,-4.94799 0.29289,-1.142 1.02772,-2.12227 1.52601,-3.19076 0.86591,-1.85678 1.56594,-3.922 2.54337,-5.59539 0.66552,-1.1394 1.92472,-1.90141 3.14452,-2.40463 1.32204,-0.5454 2.84482,-0.79679 4.25435,-0.55491 1.14144,0.19587 2.171618,0.87758 3.098268,1.57225 0.82892,0.62141 1.52639,1.42193 2.12718,2.2659 1.21154,1.70196 2.12254,3.61546 2.91329,5.54916 0.38392,0.93884 0.72328,1.91096 0.87862,2.9133 0.32345,2.08707 0.63944,4.29535 0.0925,6.33529 -0.35805,1.3354 -1.26504,2.47987 -2.12718,3.5607 -1.19654,1.50005 -2.38722,3.24719 -4.16187,3.97689 -1.625458,0.66835 -3.737098,0.94912 -5.271688,0.0925 -1.46829,-0.81963 -2.85268,-2.38304 -4.20811,-3.65318 -1.43957,-1.34899 -2.98731,-2.623 -4.16186,-4.20811 -0.38598,-0.5209 -0.78352,-1.07827 -0.92486,-1.711 z", + "roots": [ + "m 89.057752,149.36068 c 0.25498,-4.87107 1.46284,-15.84832 2.5896,-23.72263 1.0378,-7.25259 2.08507,-14.0731 3.83817,-21.64169 0.32031,-1.38284 1.03251,-3.15094 2.40463,-3.51446 0.61309,-0.16243 1.27063,0.37592 1.71099,0.83237 0.819338,0.84928 1.430568,2.05694 1.433528,3.23701 0.0207,8.23338 -0.44305,15.33744 0.0462,22.98273 0.33736,5.27183 1.20807,10.49795 1.98845,15.7226 0.20103,1.3459 0.95394,2.42655 0.69364,4.02313 -0.20581,1.26235 -1.51695,-2.09162 -2.49712,-2.9133 -0.8367,-0.70141 -1.753488,-1.43483 -2.820808,-1.66475 -1.80384,-0.38857 -3.90767,-0.32621 -5.50291,0.60116 -1.25017,0.72677 -1.78034,2.28608 -2.54336,3.51447 -0.50571,0.81414 -1.39111,3.50046 -1.34101,2.54336 z" + ] + }, + "16": { + "viewBox": "79.665565 107.085659 40.940990 77.253994", + "groupTransform": "translate(200.272121 0) scale(-1 1)", + "crown": "m 85.018788,170.22955 c -0.10444,-1.05469 0.132,-2.12813 0.39437,-3.15499 0.2659,-1.04067 0.85792,-1.97355 1.18312,-2.99724 0.33594,-1.05747 0.45041,-2.17714 0.78875,-3.23385 0.43174,-1.34841 0.79,-2.7671 1.57749,-3.94374 0.7549,-1.12796 1.73062,-2.18264 2.91836,-2.83948 1.75222,-0.969 3.86631,-1.06379 5.83672,-1.41975 1.74579,-0.31538 3.516682,-0.77832 5.284602,-0.631 1.58357,0.13196 3.13698,0.66411 4.57473,1.34087 0.97647,0.45963 1.92565,1.044 2.68174,1.81412 0.87874,0.89504 1.43679,2.06122 2.05074,3.15499 1.01307,1.80481 2.19378,3.58874 2.68174,5.60009 0.42168,1.73818 0.29217,3.57994 0.15775,5.36348 -0.0663,0.87935 -0.16474,1.77674 -0.47325,2.60286 -0.57735,1.54602 -1.42207,3.0307 -2.52399,4.25923 -0.87775,0.9786 -1.87201,2.07969 -3.15498,2.36624 -0.75988,0.16972 -1.56,-0.19547 -2.28737,-0.47325 -1.41968,-0.54217 -2.90294,-1.18002 -3.94373,-2.28736 -0.56369,-0.59973 -3.668372,-4.97137 -1.10424,-2.20849 0.30562,0.32931 0.0508,1.36853 -0.31551,1.893 -0.7618,1.09084 -2.279702,1.37829 -3.470482,1.97186 -0.94853,0.47281 -1.88578,1.02314 -2.91836,1.26199 -1.10264,0.25505 -2.3254,0.53733 -3.39161,0.15775 -1.59619,-0.56825 -2.68695,-2.10208 -3.78598,-3.39161 -0.85151,-0.9991 -1.56271,-2.12872 -2.12962,-3.31274 -0.28723,-0.59991 -0.56545,-1.23109 -0.63099,-1.89298 z", + "roots": [ + "m 89.356888,157.05748 c -0.71027,-1.59601 0.25839,-4.25788 0.23663,-6.38884 -0.0842,-8.24242 -1.44025,-16.44494 -1.41975,-24.68776 0.004,-1.68594 -0.21115,-3.4464 0.3155,-5.04798 0.30299,-0.9214 0.74069,-1.95467 1.5775,-2.44511 0.36363,-0.21312 0.88273,-0.26274 1.26199,-0.0789 0.74813,0.36269 1.16545,1.25917 1.41974,2.05074 0.49863,1.55216 -0.0948,3.26922 0.0789,4.89022 0.35714,3.33245 3.00459,16.48154 1.97186,9.85933 -0.56834,-3.64441 -0.0248,-8.90158 0.78875,-13.25094 0.53814,-2.87686 0.69686,-6.20985 2.68174,-8.3607 0.65562,-0.71044 1.770482,-1.42702 2.681732,-1.10425 0.62106,0.21998 0.83873,1.08525 0.9465,1.73524 0.25311,1.52657 -0.71551,3.02905 -0.78875,4.57473 -0.13103,2.76551 0.0579,5.57319 0.631,8.28183 0.35804,1.69219 1.61658,3.16465 1.73524,4.89023 0.59314,8.62585 2.07317,-4.86052 2.36624,-7.57196 0.22745,-2.10435 0.65605,-3.9413 1.34087,-5.67897 0.3279,-0.83202 2.07826,-2.07759 2.44511,-1.262 0.96594,2.14749 1.52585,3.3853 1.262,5.04798 -0.77771,4.90094 -1.16037,8.65698 -1.34087,13.01431 -0.16435,3.9674 -0.087,7.95248 0.23662,11.91007 0.21003,2.56813 0.68268,5.10885 1.10425,7.65084 0.14004,0.84445 0.47325,2.52398 0.47325,2.52398 0,0 -1.13641,-2.32189 -2.12962,-3.07611 -1.66208,-1.26215 -3.84373,-1.79992 -5.9156,-2.05074 -2.25437,-0.27291 -4.572412,0.11232 -6.783212,0.631 -1.92042,0.45055 -3.89261,1.01668 -5.52123,2.12962 -0.67607,0.462 -1.32346,2.56222 -1.65639,1.81411 z" + ] + }, + "17": { + "viewBox": "91.953264 97.168466 39.515047 67.713889", + "groupTransform": "translate(223.421575 0) scale(-1 1)", + "crown": "m 97.070075,155.08798 c -0.92333,-2.4489 -0.24057,-5.31833 0.46883,-7.83753 0.61491,-2.18365 1.50631,-4.49623 3.198315,-6.00739 1.51426,-1.35242 3.63744,-2.12252 5.66437,-2.23901 3.77096,-0.21673 6.80248,-0.48668 10.17875,-0.14744 2.07565,0.20856 4.43161,0.10106 6.11453,1.33378 1.15142,0.84341 1.54182,2.41377 2.13189,3.71335 0.81502,1.79502 1.75272,3.83923 1.9235,5.59261 0.25806,2.64949 -0.54068,5.03001 -2.0041,7.10627 -1.50821,2.1398 -4.30164,4.04289 -6.52147,2.80318 -2.01998,-1.1281 -3.99602,-1.17302 -5.97592,-3.9286 -1.52384,-2.12085 5.43467,-3.64985 0.17397,0.95929 -1.89016,1.65606 -5.29398,3.84301 -8.34665,3.77427 -1.45093,-0.0327 -2.70955,-1.11585 -3.88239,-1.97067 -1.195415,-0.87126 -2.601765,-1.768 -3.123625,-3.15211 z", + "roots": [ + "m 99.772075,143.52968 c -0.11891,-1.2184 0.518805,-2.39652 0.663455,-3.61212 0.57866,-4.86303 0.81088,-14.66962 0.81088,-14.66962 0,0 0.28083,-8.03734 1.54805,-11.79467 1.05876,-3.13924 2.60832,-6.27037 4.93902,-8.62485 0.5811,-0.58703 2.13778,-1.25319 2.13778,-1.25319 0,0 0.85977,0.51744 0.95832,0.95832 0.28486,1.27435 -0.99458,2.42303 -1.3269,3.68583 -0.52021,1.97678 -0.97598,4.00146 -1.03203,6.04477 -0.0827,3.01635 0.68328,5.99617 1.03203,8.99344 0.26658,2.29109 -0.74624,-4.52648 -0.51602,-6.78194 0.31669,-3.10262 1.57683,-6.04047 2.58009,-8.99343 0.60329,-1.77571 0.76213,-3.45401 2.06407,-5.23389 0.32439,-0.44347 1.11302,-0.5024 1.62177,-0.29487 0.66915,0.27297 1.06128,1.07246 1.25318,1.76921 0.24871,0.90302 -0.14224,1.86791 -0.22115,2.80123 -0.2536,2.99927 -0.52101,5.98127 -0.81089,8.99343 -0.18465,1.91877 1.16174,-3.67649 1.69549,-5.52875 0.44398,-1.54072 -0.0198,-3.66917 1.25318,-4.64415 0.33163,-0.254 0.86068,-0.14297 1.25318,0 0.75382,0.2746 1.47964,0.83274 1.84292,1.54805 0.65797,1.29559 0.34508,2.89707 0.29487,4.34929 -0.11829,3.42091 -0.91089,6.67837 -1.40062,10.1729 -0.41355,2.95089 0.17483,5.96901 0.58973,8.91972 0.4201,2.98769 1.1285,5.93368 1.91664,8.846 0.31057,1.14762 1.58098,4.63572 1.10575,3.39097 -0.36285,-0.95039 -1.02429,-1.8287 -1.84292,-2.43265 -0.69433,-0.51224 -1.58726,-0.71201 -2.43265,-0.8846 -1.7842,-0.36425 -3.63422,-0.26934 -5.45504,-0.29487 -1.89251,-0.0265 -3.78935,-0.001 -5.67618,0.14744 -1.68025,0.13264 -3.41721,0.12016 -5.01273,0.66345 -1.02136,0.34777 -2.06747,0.83075 -2.80124,1.62176 -0.53814,0.58012 -0.943305,3.04692 -1.032035,2.13779 z" + ] + }, + "18": { + "viewBox": "91.007827 100.243109 35.063725 66.119088", + "groupTransform": "translate(217.079380 0) scale(-1 1)", + "crown": "m 95.988161,150.67528 c 0.89896,-2.56651 0.79697,-2.63779 1.32689,-3.90699 0.16936,-0.40562 0.27525,-0.87237 0.58974,-1.17946 1.32125,-1.29017 3.237479,-1.85028 5.012729,-2.35894 2.40258,-0.6884 4.94672,-0.93771 7.44541,-0.8846 1.84167,0.0392 3.68695,0.36765 5.45501,0.88461 0.95074,0.27798 1.99732,0.51016 2.72753,1.17946 1.02933,0.94347 1.5145,2.37312 1.99036,3.68583 0.54689,1.50867 1.08008,3.11776 0.9583,4.71787 -0.13509,1.77513 -0.83926,3.54236 -1.84291,5.01274 -0.8145,1.19327 -2.10462,2.01897 -3.24353,2.87495 -1.06145,0.79776 -2.92202,0.65398 -3.98072,-0.14744 -1.21254,-0.91787 -3.85545,-1.09853 -3.53839,-2.94866 0.32982,-1.92457 0.72444,0.33508 -1.76921,2.28521 -1.35265,1.05782 -3.49184,2.63026 -5.74989,1.54805 -1.478669,-0.70868 -3.224909,-1.17376 -4.275579,-2.43265 -0.9511,-1.13959 -1.29045,-2.72895 -1.47433,-4.20185 -0.17115,-1.37088 0.11605,-1.98149 0.3686,-4.12813 0.14743,-1.25319 -10e-6,0 -10e-6,0 z", + "roots": [ + "m 97.978521,145.73626 c 1.75986,-3.28835 2.943009,-5.6697 3.906989,-8.69857 0.88859,-2.79198 1.18665,-5.7394 1.69549,-8.62485 0.69106,-3.9188 1.2332,-7.8624 1.84289,-11.79467 0.29711,-1.91629 0.19041,-3.93923 0.8846,-5.7499 0.57823,-1.5082 1.52959,-2.89462 2.6538,-4.05442 0.62629,-0.64612 1.35867,-1.26109 2.21152,-1.54805 0.60596,-0.2039 1.46911,-0.88759 1.91662,-0.0737 0.50538,0.91912 0.92189,1.86273 1.03203,2.87495 0.18761,1.72403 -0.48713,4.56965 -0.66343,5.16017 -1.88596,6.31702 1.05935,4.92393 0.81088,10.24662 -0.23651,5.06643 -5.73733,5.34567 0.81087,-14.22732 0.12178,-0.36401 0.54563,-1.82199 1.32691,-2.06407 0.48387,-0.14992 1.01657,0.25939 1.4006,0.58973 0.39297,0.33803 0.66014,0.83095 0.8109,1.3269 0.34333,1.12947 0.33159,2.49914 0.14742,3.5384 -0.4766,2.68944 -0.61534,3.64099 -0.95832,5.45504 -0.36796,1.94614 -0.91051,3.86134 -1.17946,5.82362 -0.18401,1.34249 -0.32301,2.69967 -0.29485,4.05441 0.0807,3.88228 0.48087,7.7837 1.32689,11.57352 0.50281,2.25239 1.3697,4.64426 2.21149,6.56079 0.94772,2.15771 -1.97494,-1.84424 -3.16981,-2.43265 -1.02665,-0.50558 -2.18562,-0.71413 -3.31724,-0.8846 -1.89665,-0.28573 -3.83951,-0.39245 -5.7499,-0.22115 -1.92989,0.17304 -3.82318,0.68682 -5.67619,1.25318 -0.96002,0.29343 -1.91509,0.63408 -2.801249,1.10575 -0.42116,0.22417 -0.95734,0.38864 -1.17945,0.81089 0,0 0,0 0,0 z" + ] + }, + "21": { + "viewBox": "51.660933 83.146350 38.944857 87.264388", + "crown": "m 62.647814,135.73384 c 0.341028,-0.92433 0.636863,-1.87523 1.110468,-2.73917 0.55297,-1.00871 1.16079,-2.02522 1.99886,-2.8132 0.78325,-0.73643 1.67713,-1.53569 2.73916,-1.70272 1.36726,-0.21503 3.01579,-0.28218 4.51592,-0.14808 0.70385,0.0629 1.41612,0.18341 2.07289,0.4442 0.88501,0.35142 1.77486,0.80219 2.44303,1.48064 0.82263,0.83529 1.25279,1.98653 1.77676,3.03529 0.37075,0.74207 0.69884,1.50842 0.96241,2.29497 0.41266,1.23148 0.63257,2.51941 0.96241,3.77561 0.4804,1.82961 0.9268,3.66959 1.48063,5.47833 0.26732,0.87304 0.62604,1.71654 0.88838,2.5911 0.227,0.75674 0.45137,1.51759 0.59225,2.29498 0.20797,1.14768 0.33655,2.31359 0.37016,3.47948 0.0441,1.53114 0.0671,3.08573 -0.2221,4.58995 -0.30059,1.56338 -0.62675,3.22229 -1.55466,4.51593 -0.39989,0.55751 -0.99264,1.00132 -1.62869,1.25853 -0.82866,0.33509 -1.77525,0.21211 -2.66514,0.29613 -2.21778,0.2094 -4.4354,0.48651 -6.66282,0.51822 -3.01395,0.0429 -6.04047,-0.0736 -9.031859,-0.44426 -1.248744,-0.15472 -2.734205,0.0644 -3.701573,-0.74031 -0.817326,-0.67987 -0.988842,-1.9163 -1.184503,-2.96127 -0.440565,-2.35292 -0.154978,-4.79822 0.07403,-7.18106 0.144009,-1.4984 0.495914,-2.97064 0.814345,-4.44188 0.38742,-1.78999 0.853156,-3.56269 1.332568,-5.33026 0.27596,-1.01745 0.551791,-2.03627 0.888379,-3.03531 0.286792,-0.85123 0.659911,-1.67125 0.96241,-2.51703 0.236518,-0.6613 0.42318,-1.33994 0.666282,-1.99885 z", + "roots": [ + "m 62.943939,135.95593 c 0,0 0.08367,-2.37479 0.222095,-3.55351 0.30378,-2.58681 0.909078,-5.12936 1.332568,-7.69928 0.46704,-2.8342 0.85124,-5.68181 1.33257,-8.51362 0.45826,-2.69605 1.23284,-5.34597 1.48063,-8.06943 0.14534,-1.59742 -0.11495,-3.21216 0,-4.81205 0.16082,-2.23823 0.29166,-4.521436 0.96241,-6.662838 0.47832,-1.527054 1.26926,-2.955761 2.14691,-4.293825 0.65331,-0.99603 1.2756,-2.122935 2.29498,-2.739168 0.49706,-0.300483 1.16538,-0.590669 1.70272,-0.370157 0.62688,0.257255 0.87646,1.066803 1.11047,1.702726 2.1479,5.836971 1.78491,12.315732 2.36901,18.507872 0.53401,5.66112 0.47036,11.36924 1.03644,17.02725 0.31998,3.19822 1.33257,9.55006 1.33257,9.55006 0,0 -1.11923,-3.66392 -2.369,-5.03414 -0.98396,-1.07879 -2.37532,-1.80722 -3.77561,-2.22094 -1.65813,-0.4899 -3.51706,-0.68761 -5.18221,-0.22213 -1.19966,0.33535 -2.2202,1.20048 -3.10933,2.07288 -0.77202,0.7575 -1.85078,2.66514 -1.85078,2.66514 l -1.036442,1.70272" + ] + }, + "22": { + "viewBox": "93.685783 101.132481 28.706127 78.417208", + "crown": "m 106.17348,144.89797 c 0,0 3.4638,-0.0314 5.03413,0.59222 0.99998,0.3971 1.91519,1.0877 2.59111,1.92482 0.56195,0.69598 0.86436,1.58304 1.11047,2.44304 0.26022,0.90931 0.23314,1.87736 0.37015,2.8132 0.33674,2.30011 0.8184,4.57871 1.11048,6.88492 0.2024,1.59807 0.37007,3.20293 0.44419,4.81206 0.0704,1.52836 0.29878,3.08942 3e-5,4.58995 -0.21387,1.07421 -0.57809,2.17701 -1.25854,3.03529 -0.60414,0.76206 -1.45491,1.37082 -2.369,1.70272 -1.58155,0.57426 -3.35233,0.31948 -5.03415,0.37016 -1.87478,0.0565 -3.76637,0.16741 -5.6264,-0.074 -0.9081,-0.11786 -2.05115,0.0131 -2.665126,-0.66628 -1.15359,-1.27648 -0.68012,-3.38871 -0.74031,-5.10817 -0.0468,-1.3354 0.12,-2.67314 0.29612,-3.9977 0.27493,-2.06772 0.744106,-4.10572 1.184496,-6.14462 0.38036,-1.76099 0.80338,-3.51308 1.25854,-5.25624 0.34341,-1.31517 0.65674,-2.64237 1.11048,-3.92366 0.31368,-0.88578 1.11046,-2.59111 1.11046,-2.59111", + "roots": [ + "m 103.65639,146.97086 c 0.28824,-1.47746 0.58952,-2.95347 0.81432,-4.4419 0.31175,-2.06411 0.4672,-4.14906 0.74031,-6.21864 0.4502,-3.41145 1.03766,-6.80395 1.48063,-10.21635 0.3901,-3.00516 0.78791,-6.01168 1.03644,-9.03185 0.16211,-1.96998 0.0603,-3.95999 0.29612,-5.92252 0.14088,-1.17246 -0.0183,-2.51724 0.66629,-3.47948 0.4294,-0.60357 1.1866,-1.04961 1.92482,-1.11047 0.84051,-0.0693 1.80618,0.26028 2.369,0.88838 0.93463,1.04303 0.78133,2.69464 1.03645,4.07173 0.46167,2.49203 0.74006,5.02113 0.88838,7.55121 0.35959,6.13409 0.0737,12.28968 0,18.43385 -0.037,3.08531 0.15869,6.19199 -0.2221,9.25394 -0.0687,0.55248 0.18433,1.57826 -0.37012,1.62869 -0.18717,0.017 -0.11387,-0.36457 -0.22209,-0.51822 -0.54298,-0.77088 -1.24391,-1.47551 -2.07289,-1.92482 -0.89452,-0.48483 -1.46818,-0.63743 -2.96125,-0.74032 -1.15735,-0.0798 -2.21081,-0.13961 -3.18335,0.29613 -0.89232,0.3998 -1.62374,1.1728 -2.14692,1.99885 -0.30484,0.48131 -0.51825,1.62871 -0.51825,1.62871 0,0 0.30428,-1.42965 0.44421,-2.14692 z" + ] + }, + "23": { + "viewBox": "92.847964 96.106573 35.728809 97.202657", + "crown": "m 99.551769,176.26584 c -0.0141,-1.55533 0.583831,-3.0579 0.977691,-4.5626 0.27842,-1.06369 0.69205,-2.08858 0.97771,-3.15036 0.33776,-1.25543 0.58103,-2.5344 0.86905,-3.80217 0.25495,-1.12221 0.43819,-2.26287 0.76044,-3.36763 0.26943,-0.92366 0.51784,-1.87069 0.9777,-2.71583 0.45692,-0.83974 1.05465,-1.61288 1.73813,-2.2813 0.70425,-0.68873 1.499,-1.31796 2.38994,-1.73813 1.04856,-0.49451 2.20982,-0.92825 3.36762,-0.86907 1.04125,0.0532 2.04158,0.54576 2.9331,1.08633 0.87688,0.53169 1.66722,1.24275 2.28131,2.06404 0.6498,0.86905 1.01396,1.9237 1.41223,2.93309 0.44557,1.1293 0.73398,2.31451 1.08633,3.47627 0.37246,1.22809 0.74648,2.45602 1.08633,3.69353 0.46516,1.69381 1.12021,3.35885 1.3036,5.10577 0.1854,1.76611 0.31102,3.62761 -0.21726,5.32303 -0.48415,1.55381 -1.62154,2.833 -2.60721,4.12806 -0.65205,0.85672 -1.34836,1.69736 -2.17266,2.38994 -0.9845,0.82718 -2.06044,1.59826 -3.259,2.06402 -0.96001,0.37306 -2.01622,0.63873 -3.04172,0.54317 -1.11828,-0.10421 -2.17801,-0.63288 -3.15037,-1.19496 -1.3367,-0.77271 -2.40515,-1.93711 -3.58491,-2.9331 -1.13747,-0.96029 -2.60004,-1.65763 -3.36762,-2.9331 -0.575191,-0.95578 -0.750311,-2.14353 -0.760431,-3.259 z", + "roots": [ + "m 117.25898,158.99315 c 0.0309,-5.1946 -0.605,-6.99748 -0.54316,-9.23383 0.11896,-4.30188 -0.1936,-7.83304 -0.54316,-11.73239 -0.34249,-3.82049 -0.99928,-7.60631 -1.52087,-11.4065 -0.42268,-3.07959 -1.02386,-6.13798 -1.3036,-9.23383 -0.2446,-2.70697 -0.51897,-5.43636 -0.3259,-8.14749 0.0994,-1.39564 0.99506,-2.74871 0.76043,-4.12807 -0.14687,-0.86342 -0.44168,-2.12583 -1.3036,-2.2813 -1.11331,-0.20081 -2.04194,1.19274 -2.6072,2.17267 -1.70742,2.95995 -1.32174,6.71125 -1.73813,10.1029 -0.83624,6.8115 -0.84156,13.70274 -1.52086,20.53168 -0.78024,7.84376 -1.61721,15.69294 -2.9331,23.46479 -0.0866,0.51119 -0.80686,1.32726 -0.3259,1.52087 0.82965,0.33397 0.90805,-1.54261 1.41223,-2.2813 0.51034,-0.74772 0.95883,-1.56461 1.6295,-2.17267 0.87875,-0.79672 1.90738,-1.50006 3.04173,-1.84676 0.72805,-0.22252 1.52279,-0.17368 2.2813,-0.10864 0.66526,0.057 1.35819,0.13593 1.9554,0.43454 0.89581,0.44791 1.65094,1.1771 2.2813,1.9554 0.57113,0.70517 1.2982,3.29735 1.30359,2.38993 z" + ] + }, + "24": { + "viewBox": "78.778460 104.429020 32.880110 81.849457", + "crown": "m 103.54613,162.08292 c 0,0 1.51697,3.7883 1.90985,5.77856 0.31375,1.5894 0.86374,3.3141 0.34277,4.84812 -0.68048,2.00371 -2.6583,3.313 -4.21149,4.75018 -1.11188,1.02883 -2.195824,2.2369 -3.623854,2.74237 -1.41645,0.50138 -3.06843,0.58631 -4.50532,0.14691 -2.15763,-0.65981 -3.32378,-1.98281 -5.48474,-3.96664 -1.74427,-1.60129 -3.27631,-3.95329 -3.52591,-6.36622 -0.21622,-2.09029 1.06703,-4.0777 1.8609,-6.02342 1.19972,-2.94044 2.39588,-5.37003 4.3584,-8.47196 0.49409,-0.78095 1.36295,-1.36623 2.25267,-1.61604 1.26081,-0.35399 2.66615,-0.0917 3.91767,0.29382 1.06231,0.32724 2.01728,0.97567 2.88928,1.66501 0.914954,0.7233 1.778634,1.55922 2.399584,2.54649 0.36314,0.57738 0.73456,1.90987 0.73456,1.90987", + "roots": [ + "m 88.707936,158.80187 c 0,0 0.8366,-12.32924 1.56707,-18.46203 0.64849,-5.44453 1.82049,-10.81728 2.49752,-16.25833 0.44545,-3.57992 0.0522,-6.93841 1.02838,-10.7736 0.26861,-1.05527 1.24173,-1.83478 2.10575,-2.49752 0.45775,-0.35111 0.99122,-0.76923 1.56707,-0.73456 0.61487,0.037 1.15915,0.51785 1.56706,0.97942 1.025294,1.16012 2.080354,2.66802 1.958854,4.2115 -0.40846,5.18879 -1.867514,9.88427 -1.909874,14.88715 -0.0826,9.75556 2.747834,21.07548 3.232084,29.08871 0.0654,1.08192 -1.14123,-1.88388 -1.95884,-2.59546 -1.240224,-1.07939 -2.540834,-1.9818 -4.309434,-2.39957 -1.53319,-0.36216 -3.32305,-0.0131 -4.65224,0.8325 -1.14637,0.7293 -2.3506,3.33002 -2.3506,3.33002" + ] + }, + "25": { + "viewBox": "80.851597 94.968970 32.298645 79.507376", + "crown": "m 86.514392,158.88671 c -0.36012,-1.61219 -0.13293,-3.34785 0.27746,-4.94799 0.29289,-1.142 1.02772,-2.12227 1.52601,-3.19076 0.86591,-1.85678 1.56594,-3.922 2.54337,-5.59539 0.66552,-1.1394 1.92472,-1.90141 3.14452,-2.40463 1.32204,-0.5454 2.84482,-0.79679 4.25435,-0.55491 1.14144,0.19587 2.171618,0.87758 3.098268,1.57225 0.82892,0.62141 1.52639,1.42193 2.12718,2.2659 1.21154,1.70196 2.12254,3.61546 2.91329,5.54916 0.38392,0.93884 0.72328,1.91096 0.87862,2.9133 0.32345,2.08707 0.63944,4.29535 0.0925,6.33529 -0.35805,1.3354 -1.26504,2.47987 -2.12718,3.5607 -1.19654,1.50005 -2.38722,3.24719 -4.16187,3.97689 -1.625458,0.66835 -3.737098,0.94912 -5.271688,0.0925 -1.46829,-0.81963 -2.85268,-2.38304 -4.20811,-3.65318 -1.43957,-1.34899 -2.98731,-2.623 -4.16186,-4.20811 -0.38598,-0.5209 -0.78352,-1.07827 -0.92486,-1.711 z", + "roots": [ + "m 89.057752,149.36068 c 0.25498,-4.87107 1.46284,-15.84832 2.5896,-23.72263 1.0378,-7.25259 2.08507,-14.0731 3.83817,-21.64169 0.32031,-1.38284 1.03251,-3.15094 2.40463,-3.51446 0.61309,-0.16243 1.27063,0.37592 1.71099,0.83237 0.819338,0.84928 1.430568,2.05694 1.433528,3.23701 0.0207,8.23338 -0.44305,15.33744 0.0462,22.98273 0.33736,5.27183 1.20807,10.49795 1.98845,15.7226 0.20103,1.3459 0.95394,2.42655 0.69364,4.02313 -0.20581,1.26235 -1.51695,-2.09162 -2.49712,-2.9133 -0.8367,-0.70141 -1.753488,-1.43483 -2.820808,-1.66475 -1.80384,-0.38857 -3.90767,-0.32621 -5.50291,0.60116 -1.25017,0.72677 -1.78034,2.28608 -2.54336,3.51447 -0.50571,0.81414 -1.39111,3.50046 -1.34101,2.54336 z" + ] + }, + "26": { + "viewBox": "79.665565 107.085659 40.940990 77.253994", + "crown": "m 85.018788,170.22955 c -0.10444,-1.05469 0.132,-2.12813 0.39437,-3.15499 0.2659,-1.04067 0.85792,-1.97355 1.18312,-2.99724 0.33594,-1.05747 0.45041,-2.17714 0.78875,-3.23385 0.43174,-1.34841 0.79,-2.7671 1.57749,-3.94374 0.7549,-1.12796 1.73062,-2.18264 2.91836,-2.83948 1.75222,-0.969 3.86631,-1.06379 5.83672,-1.41975 1.74579,-0.31538 3.516682,-0.77832 5.284602,-0.631 1.58357,0.13196 3.13698,0.66411 4.57473,1.34087 0.97647,0.45963 1.92565,1.044 2.68174,1.81412 0.87874,0.89504 1.43679,2.06122 2.05074,3.15499 1.01307,1.80481 2.19378,3.58874 2.68174,5.60009 0.42168,1.73818 0.29217,3.57994 0.15775,5.36348 -0.0663,0.87935 -0.16474,1.77674 -0.47325,2.60286 -0.57735,1.54602 -1.42207,3.0307 -2.52399,4.25923 -0.87775,0.9786 -1.87201,2.07969 -3.15498,2.36624 -0.75988,0.16972 -1.56,-0.19547 -2.28737,-0.47325 -1.41968,-0.54217 -2.90294,-1.18002 -3.94373,-2.28736 -0.56369,-0.59973 -3.668372,-4.97137 -1.10424,-2.20849 0.30562,0.32931 0.0508,1.36853 -0.31551,1.893 -0.7618,1.09084 -2.279702,1.37829 -3.470482,1.97186 -0.94853,0.47281 -1.88578,1.02314 -2.91836,1.26199 -1.10264,0.25505 -2.3254,0.53733 -3.39161,0.15775 -1.59619,-0.56825 -2.68695,-2.10208 -3.78598,-3.39161 -0.85151,-0.9991 -1.56271,-2.12872 -2.12962,-3.31274 -0.28723,-0.59991 -0.56545,-1.23109 -0.63099,-1.89298 z", + "roots": [ + "m 89.356888,157.05748 c -0.71027,-1.59601 0.25839,-4.25788 0.23663,-6.38884 -0.0842,-8.24242 -1.44025,-16.44494 -1.41975,-24.68776 0.004,-1.68594 -0.21115,-3.4464 0.3155,-5.04798 0.30299,-0.9214 0.74069,-1.95467 1.5775,-2.44511 0.36363,-0.21312 0.88273,-0.26274 1.26199,-0.0789 0.74813,0.36269 1.16545,1.25917 1.41974,2.05074 0.49863,1.55216 -0.0948,3.26922 0.0789,4.89022 0.35714,3.33245 3.00459,16.48154 1.97186,9.85933 -0.56834,-3.64441 -0.0248,-8.90158 0.78875,-13.25094 0.53814,-2.87686 0.69686,-6.20985 2.68174,-8.3607 0.65562,-0.71044 1.770482,-1.42702 2.681732,-1.10425 0.62106,0.21998 0.83873,1.08525 0.9465,1.73524 0.25311,1.52657 -0.71551,3.02905 -0.78875,4.57473 -0.13103,2.76551 0.0579,5.57319 0.631,8.28183 0.35804,1.69219 1.61658,3.16465 1.73524,4.89023 0.59314,8.62585 2.07317,-4.86052 2.36624,-7.57196 0.22745,-2.10435 0.65605,-3.9413 1.34087,-5.67897 0.3279,-0.83202 2.07826,-2.07759 2.44511,-1.262 0.96594,2.14749 1.52585,3.3853 1.262,5.04798 -0.77771,4.90094 -1.16037,8.65698 -1.34087,13.01431 -0.16435,3.9674 -0.087,7.95248 0.23662,11.91007 0.21003,2.56813 0.68268,5.10885 1.10425,7.65084 0.14004,0.84445 0.47325,2.52398 0.47325,2.52398 0,0 -1.13641,-2.32189 -2.12962,-3.07611 -1.66208,-1.26215 -3.84373,-1.79992 -5.9156,-2.05074 -2.25437,-0.27291 -4.572412,0.11232 -6.783212,0.631 -1.92042,0.45055 -3.89261,1.01668 -5.52123,2.12962 -0.67607,0.462 -1.32346,2.56222 -1.65639,1.81411 z" + ] + }, + "27": { + "viewBox": "91.953264 97.168466 39.515047 67.713889", + "crown": "m 97.070075,155.08798 c -0.92333,-2.4489 -0.24057,-5.31833 0.46883,-7.83753 0.61491,-2.18365 1.50631,-4.49623 3.198315,-6.00739 1.51426,-1.35242 3.63744,-2.12252 5.66437,-2.23901 3.77096,-0.21673 6.80248,-0.48668 10.17875,-0.14744 2.07565,0.20856 4.43161,0.10106 6.11453,1.33378 1.15142,0.84341 1.54182,2.41377 2.13189,3.71335 0.81502,1.79502 1.75272,3.83923 1.9235,5.59261 0.25806,2.64949 -0.54068,5.03001 -2.0041,7.10627 -1.50821,2.1398 -4.30164,4.04289 -6.52147,2.80318 -2.01998,-1.1281 -3.99602,-1.17302 -5.97592,-3.9286 -1.52384,-2.12085 5.43467,-3.64985 0.17397,0.95929 -1.89016,1.65606 -5.29398,3.84301 -8.34665,3.77427 -1.45093,-0.0327 -2.70955,-1.11585 -3.88239,-1.97067 -1.195415,-0.87126 -2.601765,-1.768 -3.123625,-3.15211 z", + "roots": [ + "m 99.772075,143.52968 c -0.11891,-1.2184 0.518805,-2.39652 0.663455,-3.61212 0.57866,-4.86303 0.81088,-14.66962 0.81088,-14.66962 0,0 0.28083,-8.03734 1.54805,-11.79467 1.05876,-3.13924 2.60832,-6.27037 4.93902,-8.62485 0.5811,-0.58703 2.13778,-1.25319 2.13778,-1.25319 0,0 0.85977,0.51744 0.95832,0.95832 0.28486,1.27435 -0.99458,2.42303 -1.3269,3.68583 -0.52021,1.97678 -0.97598,4.00146 -1.03203,6.04477 -0.0827,3.01635 0.68328,5.99617 1.03203,8.99344 0.26658,2.29109 -0.74624,-4.52648 -0.51602,-6.78194 0.31669,-3.10262 1.57683,-6.04047 2.58009,-8.99343 0.60329,-1.77571 0.76213,-3.45401 2.06407,-5.23389 0.32439,-0.44347 1.11302,-0.5024 1.62177,-0.29487 0.66915,0.27297 1.06128,1.07246 1.25318,1.76921 0.24871,0.90302 -0.14224,1.86791 -0.22115,2.80123 -0.2536,2.99927 -0.52101,5.98127 -0.81089,8.99343 -0.18465,1.91877 1.16174,-3.67649 1.69549,-5.52875 0.44398,-1.54072 -0.0198,-3.66917 1.25318,-4.64415 0.33163,-0.254 0.86068,-0.14297 1.25318,0 0.75382,0.2746 1.47964,0.83274 1.84292,1.54805 0.65797,1.29559 0.34508,2.89707 0.29487,4.34929 -0.11829,3.42091 -0.91089,6.67837 -1.40062,10.1729 -0.41355,2.95089 0.17483,5.96901 0.58973,8.91972 0.4201,2.98769 1.1285,5.93368 1.91664,8.846 0.31057,1.14762 1.58098,4.63572 1.10575,3.39097 -0.36285,-0.95039 -1.02429,-1.8287 -1.84292,-2.43265 -0.69433,-0.51224 -1.58726,-0.71201 -2.43265,-0.8846 -1.7842,-0.36425 -3.63422,-0.26934 -5.45504,-0.29487 -1.89251,-0.0265 -3.78935,-0.001 -5.67618,0.14744 -1.68025,0.13264 -3.41721,0.12016 -5.01273,0.66345 -1.02136,0.34777 -2.06747,0.83075 -2.80124,1.62176 -0.53814,0.58012 -0.943305,3.04692 -1.032035,2.13779 z" + ] + }, + "28": { + "viewBox": "91.007827 100.243109 35.063725 66.119088", + "crown": "m 95.988161,150.67528 c 0.89896,-2.56651 0.79697,-2.63779 1.32689,-3.90699 0.16936,-0.40562 0.27525,-0.87237 0.58974,-1.17946 1.32125,-1.29017 3.237479,-1.85028 5.012729,-2.35894 2.40258,-0.6884 4.94672,-0.93771 7.44541,-0.8846 1.84167,0.0392 3.68695,0.36765 5.45501,0.88461 0.95074,0.27798 1.99732,0.51016 2.72753,1.17946 1.02933,0.94347 1.5145,2.37312 1.99036,3.68583 0.54689,1.50867 1.08008,3.11776 0.9583,4.71787 -0.13509,1.77513 -0.83926,3.54236 -1.84291,5.01274 -0.8145,1.19327 -2.10462,2.01897 -3.24353,2.87495 -1.06145,0.79776 -2.92202,0.65398 -3.98072,-0.14744 -1.21254,-0.91787 -3.85545,-1.09853 -3.53839,-2.94866 0.32982,-1.92457 0.72444,0.33508 -1.76921,2.28521 -1.35265,1.05782 -3.49184,2.63026 -5.74989,1.54805 -1.478669,-0.70868 -3.224909,-1.17376 -4.275579,-2.43265 -0.9511,-1.13959 -1.29045,-2.72895 -1.47433,-4.20185 -0.17115,-1.37088 0.11605,-1.98149 0.3686,-4.12813 0.14743,-1.25319 -10e-6,0 -10e-6,0 z", + "roots": [ + "m 97.978521,145.73626 c 1.75986,-3.28835 2.943009,-5.6697 3.906989,-8.69857 0.88859,-2.79198 1.18665,-5.7394 1.69549,-8.62485 0.69106,-3.9188 1.2332,-7.8624 1.84289,-11.79467 0.29711,-1.91629 0.19041,-3.93923 0.8846,-5.7499 0.57823,-1.5082 1.52959,-2.89462 2.6538,-4.05442 0.62629,-0.64612 1.35867,-1.26109 2.21152,-1.54805 0.60596,-0.2039 1.46911,-0.88759 1.91662,-0.0737 0.50538,0.91912 0.92189,1.86273 1.03203,2.87495 0.18761,1.72403 -0.48713,4.56965 -0.66343,5.16017 -1.88596,6.31702 1.05935,4.92393 0.81088,10.24662 -0.23651,5.06643 -5.73733,5.34567 0.81087,-14.22732 0.12178,-0.36401 0.54563,-1.82199 1.32691,-2.06407 0.48387,-0.14992 1.01657,0.25939 1.4006,0.58973 0.39297,0.33803 0.66014,0.83095 0.8109,1.3269 0.34333,1.12947 0.33159,2.49914 0.14742,3.5384 -0.4766,2.68944 -0.61534,3.64099 -0.95832,5.45504 -0.36796,1.94614 -0.91051,3.86134 -1.17946,5.82362 -0.18401,1.34249 -0.32301,2.69967 -0.29485,4.05441 0.0807,3.88228 0.48087,7.7837 1.32689,11.57352 0.50281,2.25239 1.3697,4.64426 2.21149,6.56079 0.94772,2.15771 -1.97494,-1.84424 -3.16981,-2.43265 -1.02665,-0.50558 -2.18562,-0.71413 -3.31724,-0.8846 -1.89665,-0.28573 -3.83951,-0.39245 -5.7499,-0.22115 -1.92989,0.17304 -3.82318,0.68682 -5.67619,1.25318 -0.96002,0.29343 -1.91509,0.63408 -2.801249,1.10575 -0.42116,0.22417 -0.95734,0.38864 -1.17945,0.81089 0,0 0,0 0,0 z" + ] + }, + "31": { + "viewBox": "96.120373 98.955384 26.355569 77.981387", + "crown": "m 101.53441,106.02036 c -0.004,-0.90793 7.9e-4,-1.91402 1.4152,-1.54385 1.66002,0.43445 3.43039,0.10721 5.14619,0.12865 1.97255,0.0247 4.04665,-0.62383 5.91812,0 0.61026,0.20342 1.13168,0.66402 1.54385,1.15789 0.61444,0.73623 1.113,1.63 1.28655,2.5731 0.85803,4.66257 -0.6772,9.46894 -1.4152,14.15201 -0.50543,3.20724 -0.0832,6.94363 -2.05848,9.52045 -0.96851,1.26344 -2.65465,2.13078 -4.2456,2.18713 -1.63199,0.0578 -3.4372,-0.6925 -4.50292,-1.92982 -1.21511,-1.41076 -0.95265,-2.79984 -1.4152,-5.4035 -0.93526,-5.26444 -1.39338,-10.6136 -1.67251,-15.95318 -0.0851,-1.6274 0.008,-3.25927 0,-4.88888 z", + "roots": [ + "m 103.46423,130.72207 c 0.15401,3.18195 0.66236,7.37849 1.02924,11.0643 0.56384,5.66456 0.5816,11.42204 1.80116,16.98242 0.65952,3.00699 1.69054,5.94356 2.95906,8.74852 0.53043,1.1729 0.88055,2.59932 1.92982,3.34503 0.74728,0.53109 1.89545,0.95091 2.70175,0.51462 0.69343,-0.37521 0.81524,-1.40333 0.90059,-2.18713 0.26866,-2.46719 -1.08752,-4.85954 -1.28656,-7.33332 -0.78768,-9.78985 0.0492,-19.64622 0.38597,-29.46193 0.0265,-0.77267 0.86562,-2.08212 0.12866,-2.31579 -0.77131,-0.24456 -0.72287,1.47797 -1.28655,2.05848 -0.6463,0.66559 -1.42266,1.29282 -2.31579,1.54386 -1.03295,0.29034 -2.18083,0.15233 -3.21636,-0.12866 -0.7036,-0.19092 -1.34405,-0.59522 -1.92982,-1.02924 -0.68222,-0.50548 -1.88499,-3.5329 -1.80117,-1.80116 z" + ] + }, + "32": { + "viewBox": "90.716566 83.433116 30.144529 84.042377", + "crown": "m 96.586049,90.087282 c 0.099,-1.32852 3.15696,-0.65578 4.760231,-0.77193 2.65226,-0.19214 5.32184,-0.0252 7.97659,0.12866 1.2908,0.0748 2.73919,-0.25928 3.85964,0.38596 0.82516,0.47518 1.39149,1.40599 1.67251,2.31578 0.48111,1.55758 0.003,3.26402 -0.12865,4.88888 -0.2309,2.84725 -0.62663,5.684448 -1.1579,8.491208 -0.30371,1.60452 -0.62823,2.51235 -1.1579,4.76023 -0.49901,2.11779 -1.72922,5.17181 -2.70174,6.81869 -0.95492,1.61708 -1.57917,1.9001 -2.70174,2.18714 -1.39357,0.35634 -3.01996,-0.0191 -4.24561,-0.77193 -1.33015,-0.81703 -2.12986,-2.33597 -2.830401,-3.73099 -0.76134,-1.5161 -1.06626,-3.22862 -1.41521,-4.88888 -0.80089,-3.81054 -1.19506,-7.70078 -1.54385,-11.578928 -0.24612,-2.7366 -0.61873,-5.10979 -0.38597,-8.23389 z", + "roots": [ + "m 98.644529,110.67203 c 0.30392,5.31017 0.51318,10.2125 0.90058,15.30992 0.38515,5.06775 0.781401,10.13857 1.415201,15.18126 0.54688,4.35115 0.7191,8.81828 2.05848,12.99412 0.70291,2.19148 1.35687,4.65858 3.08771,6.17543 0.98936,0.86705 2.44386,1.55841 3.73099,1.28654 0.80163,-0.16932 1.52773,-0.90017 1.80116,-1.67251 0.40481,-1.14342 -0.3716,-2.39782 -0.51461,-3.60233 -0.48248,-4.06377 -0.97672,-8.1339 -1.15789,-12.2222 -0.32277,-7.28341 -0.27489,-14.59192 0.12865,-21.87131 0.13605,-2.45417 0.34416,-4.91289 0.77193,-7.33332 0.18464,-1.04472 0.4727,-2.01275 0.77192,-3.08771 0.79704,-2.86339 -0.63975,3.11989 -1.54385,4.37426 -0.85771,1.19 -2.04603,2.36473 -3.47368,2.70176 -1.27693,0.30144 -2.76313,-0.0515 -3.85964,-0.77194 -1.13338,-0.7447 -1.65496,-2.16078 -2.31579,-3.34502 -0.729911,-1.30803 -1.886751,-5.61241 -1.801161,-4.11695 z" + ] + }, + "33": { + "viewBox": "88.435127 109.232700 36.367350 100.346404", + "crown": "m 97.481714,139.06536 c -1.02952,-3.60735 -1.86235,-7.36964 -2.05848,-11.0643 -0.13281,-2.5018 -0.25389,-4.89725 1.54387,-6.69005 1.37473,-1.37094 2.62145,-1.85901 3.988286,-2.70174 1.42179,-0.87661 2.70826,-2.32441 4.37425,-2.44444 2.15277,-0.1551 4.16929,1.2511 6.04678,2.31578 1.99255,1.12993 4.18335,2.30699 5.40349,4.24561 0.84242,1.33848 0.89162,3.05605 1.02924,4.63157 0.16809,1.9244 0.03,3.87922 -0.25731,5.78946 -0.50738,3.37377 -1.41144,6.69856 -2.57309,9.90641 -0.85567,2.36291 -1.36536,5.11891 -3.21637,6.8187 -1.17934,1.083 -2.90498,1.77402 -4.50292,1.67251 -2.02921,-0.12891 -3.96258,-1.3958 -5.40349,-2.8304 -1.16915,-1.16403 -1.6288,-2.87428 -2.315786,-4.37426 -0.78593,-1.71601 -1.54049,-3.45989 -2.05847,-5.27485 z", + "roots": [ + "m 98.768264,141.89577 c 0.33837,4.62951 -0.008,8.41296 0.25731,12.60816 0.51203,8.09167 1.536686,16.14575 2.573086,24.18709 0.5489,4.25883 0.76347,8.60419 1.92982,12.73681 0.89783,3.18121 1.61363,6.70928 3.85964,9.13449 1.01655,1.09765 2.517,2.32957 3.9883,2.05847 1.07525,-0.19812 1.91388,-1.38577 2.18713,-2.44443 0.38479,-1.49082 -0.82342,-2.97706 -1.02924,-4.50292 -0.92386,-6.84896 -1.05905,-13.80423 -0.90058,-20.71341 0.13229,-5.76806 1.1424,-11.48414 1.54385,-17.23973 0.23901,-3.42675 -0.0569,-6.0074 0.51462,-10.29238 0.079,-0.59208 -0.24708,-0.81225 0.12865,-2.31578 1.10138,-4.40734 -0.33356,2.48653 -1.02923,3.47368 -0.79552,1.12885 -2.01307,2.07967 -3.34503,2.44443 -1.28821,0.35278 -2.76085,0.14065 -3.98829,-0.38596 -1.56457,-0.67125 -2.80294,-2.01017 -3.85964,-3.34502 -1.26203,-1.59424 -2.984186,-7.50755 -2.830396,-5.4035 z" + ] + }, + "34": { + "viewBox": "104.357943 105.031043 33.984660 82.365998", + "crown": "m 110.75011,118.8249 c 1.11967,-2.53106 3.21024,-4.61602 5.4035,-6.30408 1.09439,-0.84231 2.35205,-1.72568 3.73099,-1.80117 2.1805,-0.11937 4.30005,1.0683 6.17542,2.18714 2.08595,1.24446 4.13999,2.8144 5.4035,4.88887 0.78459,1.28818 1.03803,2.87073 1.1579,4.37426 0.17124,2.14813 -0.2266,4.31847 -0.64328,6.43274 -0.40666,2.06342 -1.4599,4.00504 -1.80117,6.04677 -0.28726,1.71858 -0.79531,3.04869 -1.28654,5.14619 -0.46386,1.98062 -3.01392,3.12222 -5.01754,3.47368 -2.31551,0.40617 -4.99997,-0.31162 -6.8187,-1.80117 -2.10698,-1.72563 -2.57377,-4.81157 -3.60233,-7.33332 -1.34702,-3.30253 -3.00032,-6.61374 -3.34502,-10.16372 -0.16708,-1.72065 -0.0561,-3.56523 0.64327,-5.14619 z", + "roots": [ + "m 114.35244,136.83656 c 0.71601,2.82716 0.97674,5.47329 1.28655,8.2339 0.48899,4.3572 0.7504,8.7408 0.90058,13.12278 0.0705,2.05727 -0.049,4.11754 0,6.17543 0.0643,2.70404 -0.18427,5.46122 0.38597,8.10524 0.51542,2.38987 1.23178,4.86516 2.70175,6.8187 0.7169,0.95273 1.68571,1.85348 2.8304,2.18713 0.90951,0.2651 2.20107,0.45082 2.8304,-0.2573 1.05446,-1.18647 -0.1341,-3.17291 -0.12865,-4.76023 0.009,-2.53165 0.094,-5.06424 0.2573,-7.59063 0.43641,-6.7495 0.95117,-13.50638 1.92983,-20.19879 0.54398,-3.71991 0.3556,-3.66457 2.18713,-11.06431 0.39773,-1.60689 -1.32091,1.8542 -2.18713,2.5731 -0.79817,0.66242 -1.72315,1.20003 -2.70175,1.54386 -0.77827,0.27344 -1.61967,0.37063 -2.44444,0.38596 -1.07963,0.0201 -2.2103,0.006 -3.21637,-0.38596 -1.23088,-0.47986 -2.34048,-1.32678 -3.21636,-2.31579 -0.64898,-0.7328 -1.65553,-3.522 -1.41521,-2.57309 z" + ] + }, + "35": { + "viewBox": "91.431724 93.169243 33.984634 82.365998", + "crown": "m 97.823805,106.96313 c 1.11967,-2.53106 3.210235,-4.61602 5.403495,-6.30408 1.09439,-0.842306 2.35205,-1.725676 3.73099,-1.801166 2.1805,-0.11937 4.30005,1.0683 6.17542,2.187136 2.08595,1.24446 4.13999,2.8144 5.4035,4.88887 0.78459,1.28818 1.03803,2.87073 1.1579,4.37426 0.17124,2.14813 -0.2266,4.31847 -0.64328,6.43274 -0.40666,2.06342 -1.4599,4.00504 -1.80117,6.04677 -0.28726,1.71858 -0.79531,3.04869 -1.28654,5.14619 -0.46386,1.98062 -3.01392,3.12222 -5.01754,3.47368 -2.31551,0.40617 -4.99997,-0.31162 -6.8187,-1.80117 -2.10698,-1.72563 -2.57377,-4.81157 -3.60233,-7.33332 -1.347015,-3.30253 -3.000315,-6.61374 -3.345015,-10.16372 -0.16708,-1.72065 -0.0561,-3.56523 0.64327,-5.14619 z", + "roots": [ + "m 101.42613,124.97479 c 0.71601,2.82716 0.97674,5.47329 1.28655,8.2339 0.48899,4.3572 0.7504,8.7408 0.90058,13.12278 0.0705,2.05727 -0.049,4.11754 0,6.17543 0.0643,2.70404 -0.18427,5.46122 0.38597,8.10524 0.51542,2.38987 1.23178,4.86516 2.70175,6.8187 0.7169,0.95273 1.68571,1.85348 2.8304,2.18713 0.90951,0.2651 2.20107,0.45082 2.8304,-0.2573 1.05446,-1.18647 -0.1341,-3.17291 -0.12865,-4.76023 0.009,-2.53165 0.094,-5.06424 0.2573,-7.59063 0.43641,-6.7495 0.95117,-13.50638 1.92983,-20.19879 0.54398,-3.71991 0.3556,-3.66457 2.18713,-11.06431 0.39773,-1.60689 -1.32091,1.8542 -2.18713,2.5731 -0.79817,0.66242 -1.72315,1.20003 -2.70175,1.54386 -0.77827,0.27344 -1.61967,0.37063 -2.44444,0.38596 -1.07963,0.0201 -2.2103,0.006 -3.21637,-0.38596 -1.23088,-0.47986 -2.34048,-1.32678 -3.21636,-2.31579 -0.64898,-0.7328 -1.65553,-3.522 -1.41521,-2.57309 z" + ] + }, + "36": { + "viewBox": "79.343303 98.222382 50.187913 81.374350", + "crown": "m 85.920764,119.68536 c -0.70467,-3.05779 -1.38674,-6.34321 -0.64328,-9.3918 0.27358,-1.12183 1.02287,-2.10606 1.80117,-2.95906 1.11575,-1.22285 2.4241,-2.41725 3.9883,-2.95905 1.41888,-0.49146 3.01428,-0.32546 4.50291,-0.12865 0.88774,0.11737 1.7672,0.38154 2.57309,0.77192 0.94223,0.45642 1.7203,1.19382 2.573086,1.80117 5.98906,4.26535 -3.522546,11.13085 -2.315776,7.46197 1.07805,-3.27756 2.957866,-5.8215 5.403496,-7.84795 1.55237,-1.2863 3.5522,-2.06463 5.53215,-2.44443 1.13794,-0.21828 2.37276,-0.23263 3.47368,0.12866 1.23187,0.40427 2.16141,1.43345 3.21636,2.18713 0.52342,0.37394 0.9336,0.95447 1.54386,1.15789 0.69163,0.23054 1.48069,-0.18014 2.18713,0 1.04137,0.26554 2.15227,0.71013 2.8304,1.54386 0.93309,1.14719 1.17402,2.77113 1.28654,4.24559 0.20768,2.72127 -0.32263,5.50703 -1.15788,8.10526 -0.71031,2.20957 -1.66604,4.44821 -3.21637,6.17542 -1.44599,1.61096 -3.35992,2.88827 -5.40349,3.60233 -2.88466,1.00795 -6.07985,0.6924 -9.1345,0.77193 -2.65934,0.0692 -4.65149,-0.32016 -7.976576,-0.25731 -2.06969,0.0391 -3.95875,-0.87706 -5.53216,-2.05847 -1.49522,-1.1227 -2.42935,-2.87266 -3.34501,-4.50291 -0.95157,-1.69418 -1.75078,-3.51001 -2.18713,-5.4035 z", + "roots": [ + "m 87.850574,124.83154 c 0.36309,3.20871 0.89909,6.93557 1.15789,10.42103 0.42265,5.69205 -0.0967,11.45153 0.64328,17.11108 0.47882,3.66217 0.84786,7.47658 2.44444,10.807 0.92358,1.92657 2.44143,3.5438 3.98829,5.01753 1.2236,1.16575 2.52424,2.3939 4.116956,2.95906 0.68826,0.24422 1.55488,0.49419 2.18712,0.12866 0.74716,-0.43197 1.0672,-1.45752 1.1579,-2.31579 0.27,-2.55493 -1.3381,-4.96189 -1.92982,-7.46198 -1.081,-4.56731 -2.192886,-8.6882 -2.959066,-13.76605 -0.3629,-2.40515 -4e-5,-5.0656 1.1579,-7.20466 0.303496,-0.56065 0.778536,-1.3199 1.415196,-1.28655 2.12325,0.11121 3.10435,2.96632 4.24561,4.76022 1.57779,2.48007 2.52185,5.32421 3.47367,8.10525 1.04751,3.06061 1.5317,6.28834 2.44445,9.3918 0.89157,3.03144 1.44848,6.23045 2.95905,9.00583 0.68518,1.25889 1.34484,2.8834 2.70175,3.34502 1.04382,0.35511 2.41444,-0.0153 3.21637,-0.77193 1.40118,-1.32213 1.5219,-3.61157 1.67251,-5.53215 0.29446,-3.75478 -0.92789,-7.47736 -1.54386,-11.19296 -0.70014,-4.22334 -2.19212,-8.33462 -2.44444,-12.60816 -0.24078,-4.07801 0.47431,-8.15939 0.90059,-12.22219 0.19872,-1.89401 4.64449,-10.69235 0.77192,-5.66082 -0.77769,1.01043 -1.76248,1.87645 -2.8304,2.5731 -1.06125,0.6923 -2.25291,1.2043 -3.47367,1.54385 -1.99862,0.55591 -4.10403,0.65871 -6.17543,0.77193 -2.82651,0.15449 -5.66347,0.001 -8.491206,-0.12865 -1.67568,-0.0771 -3.4477,0.20517 -5.01754,-0.38597 -1.80602,-0.68007 -3.33909,-2.0406 -4.63157,-3.47368 -0.50242,-0.55708 -1.37323,-3.83286 -1.15789,-1.92982 z" + ] + }, + "37": { + "viewBox": "92.485877 107.302543 48.543676 80.673353", + "crown": "m 99.521483,128.39777 c -0.69224,-2.8651 -1.94444,-5.87534 -1.28655,-8.74853 0.32389,-1.41452 1.44017,-2.55484 2.444437,-3.60233 1.14562,-1.19492 2.40234,-2.48471 3.9883,-2.95905 1.44038,-0.4308 3.06234,-0.17285 4.50291,0.25731 1.23344,0.36832 2.23156,1.28387 3.34502,1.92982 1.54601,0.89688 4.41346,0.92778 4.63156,2.70175 0.29235,2.3779 -6.35295,8.68641 -5.14618,5.01753 1.07805,-3.27756 4.14593,-5.25792 6.8187,-7.20467 1.76755,-1.28742 3.73141,-2.83539 5.91812,-2.83041 2.95376,0.007 5.9363,1.72378 7.97659,3.85966 0.90239,0.94467 1.23347,2.33333 1.54385,3.60233 0.40869,1.67096 0.49129,3.4292 0.38596,5.14618 -0.13389,2.18261 -0.70111,4.32585 -1.28653,6.43274 -0.47573,1.71214 -0.73804,3.59363 -1.80118,5.01753 -0.95449,1.27838 -2.36567,2.27058 -3.85963,2.8304 -2.86139,1.07223 -6.07985,0.6924 -9.1345,0.77193 -2.65934,0.0692 -4.65149,-0.32016 -7.97658,-0.25731 -2.06969,0.0391 -3.90504,-0.95221 -5.53216,-2.05847 -1.43137,-0.97318 -2.59796,-2.36664 -3.47366,-3.85964 -1.07724,-1.83657 -1.55844,-3.97714 -2.058477,-6.04677 z", + "roots": [ + "m 101.45129,133.54395 c 0.36309,3.20871 0.89909,6.93557 1.15789,10.42103 0.42265,5.69205 -0.0967,11.45153 0.64328,17.11108 0.47882,3.66217 0.84786,7.47658 2.44444,10.807 0.92358,1.92657 2.44143,3.5438 3.98829,5.01753 1.2236,1.16575 2.52424,2.3939 4.11696,2.95906 0.68826,0.24422 1.55488,0.49419 2.18712,0.12866 0.74716,-0.43197 1.12819,-1.45326 1.1579,-2.31579 0.0959,-2.78587 -2.43109,-5.04467 -3.21637,-7.71929 -1.23943,-4.22143 -2.32154,-7.40165 -2.31579,-12.99412 0.002,-2.43237 0.46292,-5.44104 1.80117,-7.71928 0.3229,-0.54971 0.77854,-1.3199 1.4152,-1.28655 2.12325,0.11121 3.10435,2.96632 4.24561,4.76022 1.57779,2.48007 2.36076,5.38466 3.47367,8.10525 1.0282,2.5135 2.01719,5.04353 2.95907,7.59063 0.94551,2.55693 2.04774,5.07275 2.70174,7.71929 0.48887,1.97831 -0.85882,5.01856 0.90058,6.04677 1.34777,0.78763 3.29086,-0.54866 4.37427,-1.67252 1.3836,-1.43526 1.74586,-3.67574 1.92982,-5.6608 0.30388,-3.27916 -0.86578,-6.5307 -1.41521,-9.77776 -0.71422,-4.22098 -2.01068,-8.34921 -2.44444,-12.60816 -0.37806,-3.71203 -0.4391,-7.47466 -0.12865,-11.19295 0.18128,-2.17116 0.58802,-2.99488 1.15789,-6.43276 0.16146,-0.97399 -1.32056,1.70225 -2.18713,2.3158 -1.03413,0.7322 -2.25291,1.2043 -3.47367,1.54385 -1.99862,0.55591 -4.10403,0.65871 -6.17543,0.77193 -2.82651,0.15449 -5.66347,10e-4 -8.49121,-0.12865 -1.67568,-0.0771 -3.4477,0.20517 -5.01754,-0.38597 -1.80602,-0.68007 -3.33909,-2.0406 -4.63157,-3.47368 -0.50242,-0.55708 -1.37323,-3.83286 -1.15789,-1.92982 z" + ] + }, + "38": { + "viewBox": "50.508239 85.168655 40.738012 73.286789", + "crown": "m 56.98151,106.29271 c 0,0 -1.93361,-5.44732 -1.28653,-8.105263 0.71822,-2.95019 2.6609,-6.03036 5.40349,-7.33331 1.59005,-0.7554 3.32936,-1.01362 5.27484,0.25731 1.28701,0.84077 3.03687,2.50997 4.88888,2.70174 1.16039,0.12016 2.23576,-0.66803 3.34502,-1.02924 1.37982,-0.4493 2.66638,-1.45566 4.11695,-1.41519 1.24981,0.0349 2.39542,0.78226 3.47367,1.4152 0.94726,0.55603 1.97738,1.13566 2.57311,2.05847 1.03236,1.59916 1.36173,3.62947 1.41521,5.532163 0.0749,2.66506 -0.57629,5.36359 -1.54387,7.84794 -0.65663,1.68596 -1.5122,3.39223 -2.8304,4.63157 -1.202,1.13009 -2.78269,1.88127 -4.37426,2.31578 -2.69197,0.73492 -5.57767,0.56267 -8.36256,0.38596 -2.73712,-0.17368 -5.77615,0.033 -8.10524,-1.4152 -1.17278,-0.72922 -1.8149,-2.11586 -2.44445,-3.34502 -1.00772,-1.96749 -2.05846,-6.30409 -2.05846,-6.30409", + "roots": [ + "m 58.78268,111.56754 c 0.80601,3.78491 1.53446,6.86372 2.31579,10.29238 1.14413,5.02069 1.61552,10.25015 3.47367,15.05261 2.02314,5.22887 3.88724,11.12536 8.23391,14.66663 1.53516,1.25071 3.68665,1.95506 5.66081,1.80117 1.14724,-0.0894 2.45722,-0.58123 3.08771,-1.54385 1.34119,-2.0477 0.68247,-4.90353 0.38596,-7.33333 -0.24978,-2.04686 -1.41028,-3.89346 -1.80117,-5.91812 -0.47326,-2.45128 -0.60927,-4.96565 -0.64327,-7.46197 -0.0398,-2.92094 0.0844,-5.85916 0.51462,-8.74852 0.37681,-2.53086 1.02382,-5.02415 1.80117,-7.46197 0.42096,-1.32016 0.81639,-1.43989 1.54385,-3.85964 0.65064,-2.16423 -3.51498,3.02198 -5.6608,3.73098 -1.46817,0.4851 -3.08593,0.21441 -4.63157,0.25731 -1.84334,0.0512 -5.01754,0.12866 -5.53216,0 -0.51461,-0.12865 -3.61823,-0.45349 -5.27484,-1.15789 -1.28065,-0.54454 -3.7376,-3.5551 -3.47368,-2.31579 z" + ] + }, + "41": { + "viewBox": "96.120373 98.955384 26.355569 77.981387", + "groupTransform": "translate(218.596316 0) scale(-1 1)", + "crown": "m 101.53441,106.02036 c -0.004,-0.90793 7.9e-4,-1.91402 1.4152,-1.54385 1.66002,0.43445 3.43039,0.10721 5.14619,0.12865 1.97255,0.0247 4.04665,-0.62383 5.91812,0 0.61026,0.20342 1.13168,0.66402 1.54385,1.15789 0.61444,0.73623 1.113,1.63 1.28655,2.5731 0.85803,4.66257 -0.6772,9.46894 -1.4152,14.15201 -0.50543,3.20724 -0.0832,6.94363 -2.05848,9.52045 -0.96851,1.26344 -2.65465,2.13078 -4.2456,2.18713 -1.63199,0.0578 -3.4372,-0.6925 -4.50292,-1.92982 -1.21511,-1.41076 -0.95265,-2.79984 -1.4152,-5.4035 -0.93526,-5.26444 -1.39338,-10.6136 -1.67251,-15.95318 -0.0851,-1.6274 0.008,-3.25927 0,-4.88888 z", + "roots": [ + "m 103.46423,130.72207 c 0.15401,3.18195 0.66236,7.37849 1.02924,11.0643 0.56384,5.66456 0.5816,11.42204 1.80116,16.98242 0.65952,3.00699 1.69054,5.94356 2.95906,8.74852 0.53043,1.1729 0.88055,2.59932 1.92982,3.34503 0.74728,0.53109 1.89545,0.95091 2.70175,0.51462 0.69343,-0.37521 0.81524,-1.40333 0.90059,-2.18713 0.26866,-2.46719 -1.08752,-4.85954 -1.28656,-7.33332 -0.78768,-9.78985 0.0492,-19.64622 0.38597,-29.46193 0.0265,-0.77267 0.86562,-2.08212 0.12866,-2.31579 -0.77131,-0.24456 -0.72287,1.47797 -1.28655,2.05848 -0.6463,0.66559 -1.42266,1.29282 -2.31579,1.54386 -1.03295,0.29034 -2.18083,0.15233 -3.21636,-0.12866 -0.7036,-0.19092 -1.34405,-0.59522 -1.92982,-1.02924 -0.68222,-0.50548 -1.88499,-3.5329 -1.80117,-1.80116 z" + ] + }, + "42": { + "viewBox": "90.716566 83.433116 30.144529 84.042377", + "groupTransform": "translate(211.577661 0) scale(-1 1)", + "crown": "m 96.586049,90.087282 c 0.099,-1.32852 3.15696,-0.65578 4.760231,-0.77193 2.65226,-0.19214 5.32184,-0.0252 7.97659,0.12866 1.2908,0.0748 2.73919,-0.25928 3.85964,0.38596 0.82516,0.47518 1.39149,1.40599 1.67251,2.31578 0.48111,1.55758 0.003,3.26402 -0.12865,4.88888 -0.2309,2.84725 -0.62663,5.684448 -1.1579,8.491208 -0.30371,1.60452 -0.62823,2.51235 -1.1579,4.76023 -0.49901,2.11779 -1.72922,5.17181 -2.70174,6.81869 -0.95492,1.61708 -1.57917,1.9001 -2.70174,2.18714 -1.39357,0.35634 -3.01996,-0.0191 -4.24561,-0.77193 -1.33015,-0.81703 -2.12986,-2.33597 -2.830401,-3.73099 -0.76134,-1.5161 -1.06626,-3.22862 -1.41521,-4.88888 -0.80089,-3.81054 -1.19506,-7.70078 -1.54385,-11.578928 -0.24612,-2.7366 -0.61873,-5.10979 -0.38597,-8.23389 z", + "roots": [ + "m 98.644529,110.67203 c 0.30392,5.31017 0.51318,10.2125 0.90058,15.30992 0.38515,5.06775 0.781401,10.13857 1.415201,15.18126 0.54688,4.35115 0.7191,8.81828 2.05848,12.99412 0.70291,2.19148 1.35687,4.65858 3.08771,6.17543 0.98936,0.86705 2.44386,1.55841 3.73099,1.28654 0.80163,-0.16932 1.52773,-0.90017 1.80116,-1.67251 0.40481,-1.14342 -0.3716,-2.39782 -0.51461,-3.60233 -0.48248,-4.06377 -0.97672,-8.1339 -1.15789,-12.2222 -0.32277,-7.28341 -0.27489,-14.59192 0.12865,-21.87131 0.13605,-2.45417 0.34416,-4.91289 0.77193,-7.33332 0.18464,-1.04472 0.4727,-2.01275 0.77192,-3.08771 0.79704,-2.86339 -0.63975,3.11989 -1.54385,4.37426 -0.85771,1.19 -2.04603,2.36473 -3.47368,2.70176 -1.27693,0.30144 -2.76313,-0.0515 -3.85964,-0.77194 -1.13338,-0.7447 -1.65496,-2.16078 -2.31579,-3.34502 -0.729911,-1.30803 -1.886751,-5.61241 -1.801161,-4.11695 z" + ] + }, + "43": { + "viewBox": "88.435127 109.232700 36.367350 100.346404", + "groupTransform": "translate(213.237604 0) scale(-1 1)", + "crown": "m 97.481714,139.06536 c -1.02952,-3.60735 -1.86235,-7.36964 -2.05848,-11.0643 -0.13281,-2.5018 -0.25389,-4.89725 1.54387,-6.69005 1.37473,-1.37094 2.62145,-1.85901 3.988286,-2.70174 1.42179,-0.87661 2.70826,-2.32441 4.37425,-2.44444 2.15277,-0.1551 4.16929,1.2511 6.04678,2.31578 1.99255,1.12993 4.18335,2.30699 5.40349,4.24561 0.84242,1.33848 0.89162,3.05605 1.02924,4.63157 0.16809,1.9244 0.03,3.87922 -0.25731,5.78946 -0.50738,3.37377 -1.41144,6.69856 -2.57309,9.90641 -0.85567,2.36291 -1.36536,5.11891 -3.21637,6.8187 -1.17934,1.083 -2.90498,1.77402 -4.50292,1.67251 -2.02921,-0.12891 -3.96258,-1.3958 -5.40349,-2.8304 -1.16915,-1.16403 -1.6288,-2.87428 -2.315786,-4.37426 -0.78593,-1.71601 -1.54049,-3.45989 -2.05847,-5.27485 z", + "roots": [ + "m 98.768264,141.89577 c 0.33837,4.62951 -0.008,8.41296 0.25731,12.60816 0.51203,8.09167 1.536686,16.14575 2.573086,24.18709 0.5489,4.25883 0.76347,8.60419 1.92982,12.73681 0.89783,3.18121 1.61363,6.70928 3.85964,9.13449 1.01655,1.09765 2.517,2.32957 3.9883,2.05847 1.07525,-0.19812 1.91388,-1.38577 2.18713,-2.44443 0.38479,-1.49082 -0.82342,-2.97706 -1.02924,-4.50292 -0.92386,-6.84896 -1.05905,-13.80423 -0.90058,-20.71341 0.13229,-5.76806 1.1424,-11.48414 1.54385,-17.23973 0.23901,-3.42675 -0.0569,-6.0074 0.51462,-10.29238 0.079,-0.59208 -0.24708,-0.81225 0.12865,-2.31578 1.10138,-4.40734 -0.33356,2.48653 -1.02923,3.47368 -0.79552,1.12885 -2.01307,2.07967 -3.34503,2.44443 -1.28821,0.35278 -2.76085,0.14065 -3.98829,-0.38596 -1.56457,-0.67125 -2.80294,-2.01017 -3.85964,-3.34502 -1.26203,-1.59424 -2.984186,-7.50755 -2.830396,-5.4035 z" + ] + }, + "44": { + "viewBox": "104.357943 105.031043 33.984660 82.365998", + "groupTransform": "translate(242.700545 0) scale(-1 1)", + "crown": "m 110.75011,118.8249 c 1.11967,-2.53106 3.21024,-4.61602 5.4035,-6.30408 1.09439,-0.84231 2.35205,-1.72568 3.73099,-1.80117 2.1805,-0.11937 4.30005,1.0683 6.17542,2.18714 2.08595,1.24446 4.13999,2.8144 5.4035,4.88887 0.78459,1.28818 1.03803,2.87073 1.1579,4.37426 0.17124,2.14813 -0.2266,4.31847 -0.64328,6.43274 -0.40666,2.06342 -1.4599,4.00504 -1.80117,6.04677 -0.28726,1.71858 -0.79531,3.04869 -1.28654,5.14619 -0.46386,1.98062 -3.01392,3.12222 -5.01754,3.47368 -2.31551,0.40617 -4.99997,-0.31162 -6.8187,-1.80117 -2.10698,-1.72563 -2.57377,-4.81157 -3.60233,-7.33332 -1.34702,-3.30253 -3.00032,-6.61374 -3.34502,-10.16372 -0.16708,-1.72065 -0.0561,-3.56523 0.64327,-5.14619 z", + "roots": [ + "m 114.35244,136.83656 c 0.71601,2.82716 0.97674,5.47329 1.28655,8.2339 0.48899,4.3572 0.7504,8.7408 0.90058,13.12278 0.0705,2.05727 -0.049,4.11754 0,6.17543 0.0643,2.70404 -0.18427,5.46122 0.38597,8.10524 0.51542,2.38987 1.23178,4.86516 2.70175,6.8187 0.7169,0.95273 1.68571,1.85348 2.8304,2.18713 0.90951,0.2651 2.20107,0.45082 2.8304,-0.2573 1.05446,-1.18647 -0.1341,-3.17291 -0.12865,-4.76023 0.009,-2.53165 0.094,-5.06424 0.2573,-7.59063 0.43641,-6.7495 0.95117,-13.50638 1.92983,-20.19879 0.54398,-3.71991 0.3556,-3.66457 2.18713,-11.06431 0.39773,-1.60689 -1.32091,1.8542 -2.18713,2.5731 -0.79817,0.66242 -1.72315,1.20003 -2.70175,1.54386 -0.77827,0.27344 -1.61967,0.37063 -2.44444,0.38596 -1.07963,0.0201 -2.2103,0.006 -3.21637,-0.38596 -1.23088,-0.47986 -2.34048,-1.32678 -3.21636,-2.31579 -0.64898,-0.7328 -1.65553,-3.522 -1.41521,-2.57309 z" + ] + }, + "45": { + "viewBox": "91.431724 93.169243 33.984634 82.365998", + "groupTransform": "translate(216.848081 0) scale(-1 1)", + "crown": "m 97.823805,106.96313 c 1.11967,-2.53106 3.210235,-4.61602 5.403495,-6.30408 1.09439,-0.842306 2.35205,-1.725676 3.73099,-1.801166 2.1805,-0.11937 4.30005,1.0683 6.17542,2.187136 2.08595,1.24446 4.13999,2.8144 5.4035,4.88887 0.78459,1.28818 1.03803,2.87073 1.1579,4.37426 0.17124,2.14813 -0.2266,4.31847 -0.64328,6.43274 -0.40666,2.06342 -1.4599,4.00504 -1.80117,6.04677 -0.28726,1.71858 -0.79531,3.04869 -1.28654,5.14619 -0.46386,1.98062 -3.01392,3.12222 -5.01754,3.47368 -2.31551,0.40617 -4.99997,-0.31162 -6.8187,-1.80117 -2.10698,-1.72563 -2.57377,-4.81157 -3.60233,-7.33332 -1.347015,-3.30253 -3.000315,-6.61374 -3.345015,-10.16372 -0.16708,-1.72065 -0.0561,-3.56523 0.64327,-5.14619 z", + "roots": [ + "m 101.42613,124.97479 c 0.71601,2.82716 0.97674,5.47329 1.28655,8.2339 0.48899,4.3572 0.7504,8.7408 0.90058,13.12278 0.0705,2.05727 -0.049,4.11754 0,6.17543 0.0643,2.70404 -0.18427,5.46122 0.38597,8.10524 0.51542,2.38987 1.23178,4.86516 2.70175,6.8187 0.7169,0.95273 1.68571,1.85348 2.8304,2.18713 0.90951,0.2651 2.20107,0.45082 2.8304,-0.2573 1.05446,-1.18647 -0.1341,-3.17291 -0.12865,-4.76023 0.009,-2.53165 0.094,-5.06424 0.2573,-7.59063 0.43641,-6.7495 0.95117,-13.50638 1.92983,-20.19879 0.54398,-3.71991 0.3556,-3.66457 2.18713,-11.06431 0.39773,-1.60689 -1.32091,1.8542 -2.18713,2.5731 -0.79817,0.66242 -1.72315,1.20003 -2.70175,1.54386 -0.77827,0.27344 -1.61967,0.37063 -2.44444,0.38596 -1.07963,0.0201 -2.2103,0.006 -3.21637,-0.38596 -1.23088,-0.47986 -2.34048,-1.32678 -3.21636,-2.31579 -0.64898,-0.7328 -1.65553,-3.522 -1.41521,-2.57309 z" + ] + }, + "46": { + "viewBox": "79.343303 98.222382 50.187913 81.374350", + "groupTransform": "translate(208.874519 0) scale(-1 1)", + "crown": "m 85.920764,119.68536 c -0.70467,-3.05779 -1.38674,-6.34321 -0.64328,-9.3918 0.27358,-1.12183 1.02287,-2.10606 1.80117,-2.95906 1.11575,-1.22285 2.4241,-2.41725 3.9883,-2.95905 1.41888,-0.49146 3.01428,-0.32546 4.50291,-0.12865 0.88774,0.11737 1.7672,0.38154 2.57309,0.77192 0.94223,0.45642 1.7203,1.19382 2.573086,1.80117 5.98906,4.26535 -3.522546,11.13085 -2.315776,7.46197 1.07805,-3.27756 2.957866,-5.8215 5.403496,-7.84795 1.55237,-1.2863 3.5522,-2.06463 5.53215,-2.44443 1.13794,-0.21828 2.37276,-0.23263 3.47368,0.12866 1.23187,0.40427 2.16141,1.43345 3.21636,2.18713 0.52342,0.37394 0.9336,0.95447 1.54386,1.15789 0.69163,0.23054 1.48069,-0.18014 2.18713,0 1.04137,0.26554 2.15227,0.71013 2.8304,1.54386 0.93309,1.14719 1.17402,2.77113 1.28654,4.24559 0.20768,2.72127 -0.32263,5.50703 -1.15788,8.10526 -0.71031,2.20957 -1.66604,4.44821 -3.21637,6.17542 -1.44599,1.61096 -3.35992,2.88827 -5.40349,3.60233 -2.88466,1.00795 -6.07985,0.6924 -9.1345,0.77193 -2.65934,0.0692 -4.65149,-0.32016 -7.976576,-0.25731 -2.06969,0.0391 -3.95875,-0.87706 -5.53216,-2.05847 -1.49522,-1.1227 -2.42935,-2.87266 -3.34501,-4.50291 -0.95157,-1.69418 -1.75078,-3.51001 -2.18713,-5.4035 z", + "roots": [ + "m 87.850574,124.83154 c 0.36309,3.20871 0.89909,6.93557 1.15789,10.42103 0.42265,5.69205 -0.0967,11.45153 0.64328,17.11108 0.47882,3.66217 0.84786,7.47658 2.44444,10.807 0.92358,1.92657 2.44143,3.5438 3.98829,5.01753 1.2236,1.16575 2.52424,2.3939 4.116956,2.95906 0.68826,0.24422 1.55488,0.49419 2.18712,0.12866 0.74716,-0.43197 1.0672,-1.45752 1.1579,-2.31579 0.27,-2.55493 -1.3381,-4.96189 -1.92982,-7.46198 -1.081,-4.56731 -2.192886,-8.6882 -2.959066,-13.76605 -0.3629,-2.40515 -4e-5,-5.0656 1.1579,-7.20466 0.303496,-0.56065 0.778536,-1.3199 1.415196,-1.28655 2.12325,0.11121 3.10435,2.96632 4.24561,4.76022 1.57779,2.48007 2.52185,5.32421 3.47367,8.10525 1.04751,3.06061 1.5317,6.28834 2.44445,9.3918 0.89157,3.03144 1.44848,6.23045 2.95905,9.00583 0.68518,1.25889 1.34484,2.8834 2.70175,3.34502 1.04382,0.35511 2.41444,-0.0153 3.21637,-0.77193 1.40118,-1.32213 1.5219,-3.61157 1.67251,-5.53215 0.29446,-3.75478 -0.92789,-7.47736 -1.54386,-11.19296 -0.70014,-4.22334 -2.19212,-8.33462 -2.44444,-12.60816 -0.24078,-4.07801 0.47431,-8.15939 0.90059,-12.22219 0.19872,-1.89401 4.64449,-10.69235 0.77192,-5.66082 -0.77769,1.01043 -1.76248,1.87645 -2.8304,2.5731 -1.06125,0.6923 -2.25291,1.2043 -3.47367,1.54385 -1.99862,0.55591 -4.10403,0.65871 -6.17543,0.77193 -2.82651,0.15449 -5.66347,0.001 -8.491206,-0.12865 -1.67568,-0.0771 -3.4477,0.20517 -5.01754,-0.38597 -1.80602,-0.68007 -3.33909,-2.0406 -4.63157,-3.47368 -0.50242,-0.55708 -1.37323,-3.83286 -1.15789,-1.92982 z" + ] + }, + "47": { + "viewBox": "92.485877 107.302543 48.543676 80.673353", + "groupTransform": "translate(233.515429 0) scale(-1 1)", + "crown": "m 99.521483,128.39777 c -0.69224,-2.8651 -1.94444,-5.87534 -1.28655,-8.74853 0.32389,-1.41452 1.44017,-2.55484 2.444437,-3.60233 1.14562,-1.19492 2.40234,-2.48471 3.9883,-2.95905 1.44038,-0.4308 3.06234,-0.17285 4.50291,0.25731 1.23344,0.36832 2.23156,1.28387 3.34502,1.92982 1.54601,0.89688 4.41346,0.92778 4.63156,2.70175 0.29235,2.3779 -6.35295,8.68641 -5.14618,5.01753 1.07805,-3.27756 4.14593,-5.25792 6.8187,-7.20467 1.76755,-1.28742 3.73141,-2.83539 5.91812,-2.83041 2.95376,0.007 5.9363,1.72378 7.97659,3.85966 0.90239,0.94467 1.23347,2.33333 1.54385,3.60233 0.40869,1.67096 0.49129,3.4292 0.38596,5.14618 -0.13389,2.18261 -0.70111,4.32585 -1.28653,6.43274 -0.47573,1.71214 -0.73804,3.59363 -1.80118,5.01753 -0.95449,1.27838 -2.36567,2.27058 -3.85963,2.8304 -2.86139,1.07223 -6.07985,0.6924 -9.1345,0.77193 -2.65934,0.0692 -4.65149,-0.32016 -7.97658,-0.25731 -2.06969,0.0391 -3.90504,-0.95221 -5.53216,-2.05847 -1.43137,-0.97318 -2.59796,-2.36664 -3.47366,-3.85964 -1.07724,-1.83657 -1.55844,-3.97714 -2.058477,-6.04677 z", + "roots": [ + "m 101.45129,133.54395 c 0.36309,3.20871 0.89909,6.93557 1.15789,10.42103 0.42265,5.69205 -0.0967,11.45153 0.64328,17.11108 0.47882,3.66217 0.84786,7.47658 2.44444,10.807 0.92358,1.92657 2.44143,3.5438 3.98829,5.01753 1.2236,1.16575 2.52424,2.3939 4.11696,2.95906 0.68826,0.24422 1.55488,0.49419 2.18712,0.12866 0.74716,-0.43197 1.12819,-1.45326 1.1579,-2.31579 0.0959,-2.78587 -2.43109,-5.04467 -3.21637,-7.71929 -1.23943,-4.22143 -2.32154,-7.40165 -2.31579,-12.99412 0.002,-2.43237 0.46292,-5.44104 1.80117,-7.71928 0.3229,-0.54971 0.77854,-1.3199 1.4152,-1.28655 2.12325,0.11121 3.10435,2.96632 4.24561,4.76022 1.57779,2.48007 2.36076,5.38466 3.47367,8.10525 1.0282,2.5135 2.01719,5.04353 2.95907,7.59063 0.94551,2.55693 2.04774,5.07275 2.70174,7.71929 0.48887,1.97831 -0.85882,5.01856 0.90058,6.04677 1.34777,0.78763 3.29086,-0.54866 4.37427,-1.67252 1.3836,-1.43526 1.74586,-3.67574 1.92982,-5.6608 0.30388,-3.27916 -0.86578,-6.5307 -1.41521,-9.77776 -0.71422,-4.22098 -2.01068,-8.34921 -2.44444,-12.60816 -0.37806,-3.71203 -0.4391,-7.47466 -0.12865,-11.19295 0.18128,-2.17116 0.58802,-2.99488 1.15789,-6.43276 0.16146,-0.97399 -1.32056,1.70225 -2.18713,2.3158 -1.03413,0.7322 -2.25291,1.2043 -3.47367,1.54385 -1.99862,0.55591 -4.10403,0.65871 -6.17543,0.77193 -2.82651,0.15449 -5.66347,10e-4 -8.49121,-0.12865 -1.67568,-0.0771 -3.4477,0.20517 -5.01754,-0.38597 -1.80602,-0.68007 -3.33909,-2.0406 -4.63157,-3.47368 -0.50242,-0.55708 -1.37323,-3.83286 -1.15789,-1.92982 z" + ] + }, + "48": { + "viewBox": "50.508239 85.168655 40.738012 73.286789", + "groupTransform": "translate(141.754490 0) scale(-1 1)", + "crown": "m 56.98151,106.29271 c 0,0 -1.93361,-5.44732 -1.28653,-8.105263 0.71822,-2.95019 2.6609,-6.03036 5.40349,-7.33331 1.59005,-0.7554 3.32936,-1.01362 5.27484,0.25731 1.28701,0.84077 3.03687,2.50997 4.88888,2.70174 1.16039,0.12016 2.23576,-0.66803 3.34502,-1.02924 1.37982,-0.4493 2.66638,-1.45566 4.11695,-1.41519 1.24981,0.0349 2.39542,0.78226 3.47367,1.4152 0.94726,0.55603 1.97738,1.13566 2.57311,2.05847 1.03236,1.59916 1.36173,3.62947 1.41521,5.532163 0.0749,2.66506 -0.57629,5.36359 -1.54387,7.84794 -0.65663,1.68596 -1.5122,3.39223 -2.8304,4.63157 -1.202,1.13009 -2.78269,1.88127 -4.37426,2.31578 -2.69197,0.73492 -5.57767,0.56267 -8.36256,0.38596 -2.73712,-0.17368 -5.77615,0.033 -8.10524,-1.4152 -1.17278,-0.72922 -1.8149,-2.11586 -2.44445,-3.34502 -1.00772,-1.96749 -2.05846,-6.30409 -2.05846,-6.30409", + "roots": [ + "m 58.78268,111.56754 c 0.80601,3.78491 1.53446,6.86372 2.31579,10.29238 1.14413,5.02069 1.61552,10.25015 3.47367,15.05261 2.02314,5.22887 3.88724,11.12536 8.23391,14.66663 1.53516,1.25071 3.68665,1.95506 5.66081,1.80117 1.14724,-0.0894 2.45722,-0.58123 3.08771,-1.54385 1.34119,-2.0477 0.68247,-4.90353 0.38596,-7.33333 -0.24978,-2.04686 -1.41028,-3.89346 -1.80117,-5.91812 -0.47326,-2.45128 -0.60927,-4.96565 -0.64327,-7.46197 -0.0398,-2.92094 0.0844,-5.85916 0.51462,-8.74852 0.37681,-2.53086 1.02382,-5.02415 1.80117,-7.46197 0.42096,-1.32016 0.81639,-1.43989 1.54385,-3.85964 0.65064,-2.16423 -3.51498,3.02198 -5.6608,3.73098 -1.46817,0.4851 -3.08593,0.21441 -4.63157,0.25731 -1.84334,0.0512 -5.01754,0.12866 -5.53216,0 -0.51461,-0.12865 -3.61823,-0.45349 -5.27484,-1.15789 -1.28065,-0.54454 -3.7376,-3.5551 -3.47368,-2.31579 z" + ] + } +}; + +export function getRealisticToothAsset(fdi: FdiToothId): RealisticToothAsset | null { + return REALISTIC_TOOTH_ASSETS[fdi] ?? null; +} diff --git a/frontend/src/components/treatment/toothSelectionGroups.ts b/frontend/src/components/treatment/toothSelectionGroups.ts index 42483c1..1fbcccd 100644 --- a/frontend/src/components/treatment/toothSelectionGroups.ts +++ b/frontend/src/components/treatment/toothSelectionGroups.ts @@ -23,7 +23,7 @@ export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId return [...set].sort() as FdiToothId[]; } -/** Never allow a 1-tooth connected group (no lone connected dots). */ +/** Never allow a 1-tooth connected group (no lone connection marks). */ function normalizeGroupKind(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionKind { if (kind === 'connected' && teeth.length < 2) return 'single'; return kind; @@ -34,7 +34,7 @@ function makeGroup(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectio return { groupId: newGroupId(), kind: normalizeGroupKind(kind, teeth), - teeth, + teeth: sortInArchOrder(teeth), }; } @@ -61,6 +61,7 @@ export function groupsFromFlatTeeth( .map((g) => ({ ...g, kind: normalizeGroupKind(g.kind, g.teeth), + teeth: sortInArchOrder(g.teeth), })); const covered = new Set(next.flatMap((g) => g.teeth)); for (const tooth of teeth) { @@ -86,6 +87,40 @@ export function connectedTeethSet(groups: ToothSelectionGroup[]): Set { + const edges = new Set(); + for (const group of groups) { + if (group.kind !== 'connected' || group.teeth.length < 2) continue; + const ordered = sortInArchOrder(group.teeth); + for (let i = 0; i < ordered.length - 1; i++) { + const a = ordered[i]; + const b = ordered[i + 1]; + if (areArchNeighbors(a, b)) { + edges.add(toothEdgeKey(a, b)); + } + } + } + return edges; +} + +export function areArchNeighbors(a: FdiToothId, b: FdiToothId): boolean { + const arch = archOrder(a); + if (!arch || !sameArch(a, b)) return false; + return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1; +} + function archOrder(tooth: FdiToothId): FdiToothId[] | null { if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) { return FDI_UPPER_LEFT_TO_RIGHT; @@ -115,11 +150,7 @@ export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[ /** * Plain click: * - unselected → add as single - * - selected single → remove (deselect) - * - in connected bridge → cut from bridge, keep selected as single: - * - end tooth → remainder stays one bridge (or demotes if 1 left) - * - middle tooth → left and right become separate bridges/singles - * Never leaves a 1-tooth connected group (no lone dots). + * - selected → deselect (remove). If it was in a bridge, split/shrink remaining sides. */ export function toggleToothInGroups( groups: ToothSelectionGroup[], @@ -146,14 +177,69 @@ export function toggleToothInGroups( const rightGroup = makeGroup(right.length >= 2 ? 'connected' : 'single', right); if (leftGroup) next.push(leftGroup); if (rightGroup) next.push(rightGroup); - next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] }); + return next; +} + +/** Connect two adjacent selected teeth (merge into one connected bridge). */ +export function linkAdjacentTeeth( + groups: ToothSelectionGroup[], + a: FdiToothId, + b: FdiToothId, +): ToothSelectionGroup[] | null { + if (!areArchNeighbors(a, b)) return null; + const groupA = groups.find((g) => g.teeth.includes(a)); + const groupB = groups.find((g) => g.teeth.includes(b)); + if (!groupA || !groupB) return null; + + if (groupA.groupId === groupB.groupId) { + if (groupA.kind === 'connected') return groups; + return groups.map((g) => + g.groupId === groupA.groupId + ? { ...g, kind: 'connected' as const, teeth: sortInArchOrder(g.teeth) } + : g, + ); + } + + const mergedTeeth = sortInArchOrder([...groupA.teeth, ...groupB.teeth]); + const next = groups.filter((g) => g.groupId !== groupA.groupId && g.groupId !== groupB.groupId); + const merged = makeGroup('connected', mergedTeeth); + if (merged) next.push(merged); + return next; +} + +/** Disconnect one edge inside a bridge; teeth stay selected. */ +export function unlinkAdjacentTeeth( + groups: ToothSelectionGroup[], + a: FdiToothId, + b: FdiToothId, +): ToothSelectionGroup[] | null { + if (!areArchNeighbors(a, b)) return null; + const owning = groups.find( + (g) => g.kind === 'connected' && g.teeth.includes(a) && g.teeth.includes(b), + ); + if (!owning) return null; + + const ordered = sortInArchOrder(owning.teeth); + const i = ordered.indexOf(a); + const j = ordered.indexOf(b); + if (i < 0 || j < 0 || Math.abs(i - j) !== 1) return null; + + const [leftIdx, rightIdx] = i < j ? [i, j] : [j, i]; + const left = ordered.slice(0, rightIdx); + const right = ordered.slice(rightIdx); + const next = groups.filter((g) => g.groupId !== owning.groupId); + const leftGroup = makeGroup(left.length >= 2 ? 'connected' : 'single', left); + const rightGroup = makeGroup(right.length >= 2 ? 'connected' : 'single', right); + if (leftGroup) next.push(leftGroup); + if (rightGroup) next.push(rightGroup); return next; } /** - * Shift-click creates a connected span between anchor and target on the same arch. - * Overlapping existing bridges are merged (union) into one connected bridge. - * Non-overlapping bridges are left alone. Singles inside the union are absorbed. + * Shift-click selects an inclusive same-arch span as singles (empty circles between + * neighbors — not connected). Overlapping existing bridges are absorbed into that + * selection and become singles too (one selected range, not connected). + * Midline pairs (11–21, 41–31) are allowed — they are neighbors in arch order. */ export function applyShiftRange( groups: ToothSelectionGroup[], @@ -194,11 +280,9 @@ export function applyShiftRange( kind: normalizeGroupKind(g.kind, g.teeth), })); - next.push({ - groupId: newGroupId(), - kind: 'connected', - teeth: unionTeeth, - }); + for (const tooth of unionTeeth) { + next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] }); + } return next; } @@ -238,7 +322,7 @@ export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGro out.push({ groupId, kind: normalizeGroupKind(kind, teeth), - teeth, + teeth: sortInArchOrder(teeth), }); } return out; diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts index db8ff79..594c574 100644 --- a/frontend/src/components/treatment/treatmentDetailRules.ts +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -44,15 +44,39 @@ export function isDetailTypeSelected(detail: Pick): } export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean { + return isBlankTreatmentDetail(detail); +} + +/** Strip-card delete: no type, teeth, notes, or attachments — including an empty list. */ +export function isBlankTreatmentDetail(detail: { + treatmentType?: string | null; + teeth?: readonly string[] | null; + comment?: string | null; + notes?: string | null; + attachmentMetas?: readonly unknown[] | null; +}): boolean { return ( - !isDetailTypeSelected(detail) && - detail.teeth.length === 0 && - !detail.comment.trim() + !detail.treatmentType?.trim() && + (detail.teeth?.length ?? 0) === 0 && + !`${detail.comment ?? ''}${detail.notes ?? ''}`.trim() && + (detail.attachmentMetas?.length ?? 0) === 0 ); } +export function areUnscheduledDetailsStripDeletable( + details: readonly { + treatmentType?: string | null; + teeth?: readonly string[] | null; + comment?: string | null; + notes?: string | null; + attachmentMetas?: readonly unknown[] | null; + }[], +): boolean { + return details.every(isBlankTreatmentDetail); +} + export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean { - return details.length > 0 && details.every((d) => isDetailTypeSelected(d)); + return details.every((d) => isDetailTypeSelected(d)); } /** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */ diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index f5c69cd..ccbcab3 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -219,9 +219,24 @@ export function AppointmentsPage() { return (
-
-

{t('title')}

-

{t('subtitle')}

+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ setScheduleDate(startOfLocalDay(d))} + /> +
+
+ {loadingSchedule ? ( +

{t('loadingSchedule')}

+ ) : null} +
@@ -248,16 +263,6 @@ export function AppointmentsPage() {
-
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- ; + attachments: LabCaseAttachmentMeta[]; +}; + +function newLine(): LineDraft { + const clientId = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `line-${Date.now()}`; + return { + clientId, + teeth: [], + toothSelectionGroups: [], + comment: '', + toothProsthesis: [], + attachments: [], + }; +} + +function linesFromDetail(labCase: LabCaseDetail): LineDraft[] { + const source = labCase.lines ?? []; + if (source.length === 0) return [newLine()]; + return source.map((line) => { + const groups = normalizeToothSelectionGroups(line.toothSelectionGroups); + const teeth = (line.teeth as FdiToothId[]) ?? []; + return { + clientId: line.clientId || line.id, + id: line.id, + teeth, + toothSelectionGroups: groups.length ? groups : groupsFromFlatTeeth(teeth), + comment: line.comment ?? '', + toothProsthesis: labCase.toothProsthesis + .filter((tp) => tp.lineId === line.id) + .map((tp) => ({ + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId, + detailClientId: line.clientId || line.id, + })), + attachments: labCase.attachments.filter( + (a) => a.detailClientKey === (line.clientId || line.id), + ), + }; + }); +} + +interface CaseCreatePanelProps { + labCase: LabCaseDetail; + canEdit: boolean; + onSaved: (detail: LabCaseDetail) => void; + onStarted: (detail: LabCaseDetail) => void; + onDeleted: () => void; + onError: (message: string) => void; +} + +export function CaseCreatePanel({ + labCase, + canEdit, + onSaved, + onStarted, + onDeleted, + onError, +}: CaseCreatePanelProps) { + const t = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const tErrors = useTranslations('errors'); + const tCommon = useTranslations('common'); + + const [referringClinicName, setReferringClinicName] = useState( + labCase.referringClinicName ?? '', + ); + const [referringDentistName, setReferringDentistName] = useState( + labCase.referringDentistName ?? '', + ); + const [patientDisplayName, setPatientDisplayName] = useState( + labCase.patientDisplayName ?? '', + ); + const [patientDisplayMobile, setPatientDisplayMobile] = useState( + labCase.patientDisplayMobile ?? '', + ); + const [partnerClinicOrganizationId, setPartnerClinicOrganizationId] = useState( + labCase.partnerClinicOrganizationId ?? '', + ); + const [dueDate, setDueDate] = useState(toDateInputValue(labCase.dueDate)); + const [lines, setLines] = useState(() => linesFromDetail(labCase)); + const [activeLineId, setActiveLineId] = useState( + () => linesFromDetail(labCase)[0]?.clientId ?? '', + ); + const [partners, setPartners] = useState([]); + const [prosthesisOptions, setProsthesisOptions] = useState([]); + const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); + const [saving, setSaving] = useState(false); + const [starting, setStarting] = useState(false); + const [deleting, setDeleting] = useState(false); + const [uploadBusy, setUploadBusy] = useState(false); + const rangeAnchorRef = useRef(null); + const hydratedIdRef = useRef(labCase.id); + const skipSaveRef = useRef(true); + const startingRef = useRef(false); + const deletingRef = useRef(false); + const allowPersistWhileStartingRef = useRef(false); + const attachmentInputRef = useRef(null); + + useEffect(() => { + if (hydratedIdRef.current === labCase.id) return; + hydratedIdRef.current = labCase.id; + skipSaveRef.current = true; + const nextLines = linesFromDetail(labCase); + setReferringClinicName(labCase.referringClinicName ?? ''); + setReferringDentistName(labCase.referringDentistName ?? ''); + setPatientDisplayName(labCase.patientDisplayName ?? ''); + setPatientDisplayMobile(labCase.patientDisplayMobile ?? ''); + setPartnerClinicOrganizationId(labCase.partnerClinicOrganizationId ?? ''); + setDueDate(toDateInputValue(labCase.dueDate)); + setLines(nextLines); + setActiveLineId(nextLines[0]?.clientId ?? ''); + }, [labCase]); + + useEffect(() => { + void casesApi.listLinkedClinics().then((r) => setPartners(r.data)).catch(() => undefined); + void prosthesisCatalogApi.list().then((r) => setProsthesisOptions(r.data)).catch(() => undefined); + }, []); + + const activeLine = lines.find((l) => l.clientId === activeLineId) ?? lines[0]; + const groups = + activeLine?.toothSelectionGroups.length + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine?.teeth ?? []); + const selected = new Set(activeLine?.teeth ?? []); + const linkedEdges = linkedEdgesFromGroups(groups); + + const prosthesisRows = groups.map((g) => ({ + groupId: g.groupId, + kind: g.kind, + teeth: g.teeth, + })); + + const toothColors = useMemo(() => { + const colors: Partial> = {}; + for (const tp of activeLine?.toothProsthesis ?? []) { + const color = prosthesisTypeColorFromCatalog(tp.prosthesisTypeCode, prosthesisOptions); + if (color) colors[tp.tooth as FdiToothId] = color; + } + return colors; + }, [activeLine?.toothProsthesis, prosthesisOptions]); + + const buildPayload = useCallback( + () => ({ + referringClinicName: referringClinicName.trim() || null, + referringDentistName: referringDentistName.trim() || null, + patientDisplayName: patientDisplayName.trim() || null, + patientDisplayMobile: patientDisplayMobile.trim() || null, + partnerClinicOrganizationId: partnerClinicOrganizationId || null, + dueDate: dueDate || null, + lines: lines.map((line) => ({ + clientId: line.clientId, + id: line.id, + teeth: line.teeth, + toothSelectionGroups: line.toothSelectionGroups, + comment: line.comment, + toothProsthesis: line.toothProsthesis.map((tp) => ({ + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId, + })), + attachmentIds: line.attachments.map((a) => a.id), + })), + }), + [ + referringClinicName, + referringDentistName, + patientDisplayName, + patientDisplayMobile, + partnerClinicOrganizationId, + dueDate, + lines, + ], + ); + + const persist = useCallback(async () => { + if (deletingRef.current) { + return null; + } + if (startingRef.current && !allowPersistWhileStartingRef.current) { + return null; + } + setSaving(true); + try { + const response = await casesApi.update(labCase.id, buildPayload()); + setLines((prev) => + prev.map((line) => { + const saved = response.data.lines?.find((l) => l.clientId === line.clientId); + return saved ? { ...line, id: saved.id } : line; + }), + ); + onSaved(response.data); + return response.data; + } catch (error: unknown) { + onError(getUserFacingError(error, tErrors, t('errorSaveCase'))); + return null; + } finally { + setSaving(false); + } + }, [buildPayload, labCase.id, onError, onSaved, t, tErrors]); + + const persistRef = useRef(persist); + persistRef.current = persist; + + useEffect(() => { + if (skipSaveRef.current) { + skipSaveRef.current = false; + return; + } + if (!canEdit || startingRef.current || deletingRef.current) return; + const timeout = setTimeout(() => { + void persistRef.current(); + }, 500); + return () => clearTimeout(timeout); + }, [buildPayload, canEdit]); + + function updateActiveLine(patch: (line: LineDraft) => LineDraft) { + setLines((prev) => + prev.map((line) => (line.clientId === activeLine?.clientId ? patch(line) : line)), + ); + } + + async function handleDelete() { + if (!canEdit) return; + if (!window.confirm(t('confirmDeleteDraftCase'))) return; + deletingRef.current = true; + skipSaveRef.current = true; + setDeleting(true); + try { + await casesApi.deleteDraft(labCase.id); + onDeleted(); + } catch (error: unknown) { + deletingRef.current = false; + setDeleting(false); + onError(getUserFacingError(error, tErrors, t('errorDeleteCase'))); + } + } + + async function handleStart() { + if (!canEdit) return; + setStarting(true); + startingRef.current = true; + skipSaveRef.current = true; + allowPersistWhileStartingRef.current = true; + try { + const saved = await persist(); + allowPersistWhileStartingRef.current = false; + if (!saved) { + startingRef.current = false; + return; + } + skipSaveRef.current = true; + const response = await casesApi.start(labCase.id); + onStarted(response.data); + } catch (error: unknown) { + startingRef.current = false; + onError(getUserFacingError(error, tErrors, t('errorStartCase'))); + } finally { + allowPersistWhileStartingRef.current = false; + setStarting(false); + } + } + + async function handleUpload(files: FileList | null) { + if (!files?.length || !activeLine || !canEdit) return; + setUploadBusy(true); + try { + await persist(); + const response = await casesApi.uploadLineAttachments( + labCase.id, + activeLine.clientId, + Array.from(files), + ); + updateActiveLine((line) => ({ + ...line, + attachments: [...line.attachments, ...response.data], + })); + } catch (error: unknown) { + onError(getUserFacingError(error, tErrors, t('errorSaveCase'))); + } finally { + setUploadBusy(false); + if (attachmentInputRef.current) attachmentInputRef.current.value = ''; + } + } + + const disabled = !canEdit || starting || deleting; + + return ( +
+
+
+

{t('addCaseTitle')}

+

{t('addCaseSubtitle')}

+
+
+ + +
+
+ +
+ + + + + + +
+ +
+

{t('caseLinesTitle')}

+ +
+ +
+ {lines.map((line, idx) => { + const isActive = line.clientId === activeLine?.clientId; + return ( +
+ + {lines.length > 1 ? ( + + ) : null} +
+ ); + })} +
+ + {activeLine ? ( + <> + { + if (disabled) return; + const currentGroups = + activeLine.toothSelectionGroups.length > 0 + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine.teeth); + let nextGroups: ToothSelectionGroup[] | null = null; + if (event.shiftKey) { + const anchor = rangeAnchorRef.current; + if (!anchor || anchor === fdi) { + rangeAnchorRef.current = fdi; + return; + } + nextGroups = applyShiftRange(currentGroups, anchor, fdi); + rangeAnchorRef.current = fdi; + } else { + nextGroups = toggleToothInGroups(currentGroups, fdi); + rangeAnchorRef.current = fdi; + } + if (!nextGroups) return; + updateActiveLine((line) => ({ + ...line, + toothSelectionGroups: nextGroups!, + teeth: deriveTeethFromGroups(nextGroups!), + toothProsthesis: pruneToothProsthesisForGroups( + line.toothProsthesis, + line.clientId, + nextGroups!, + ), + })); + }} + onToggleLink={(a, b) => { + if (disabled) return; + const currentGroups = + activeLine.toothSelectionGroups.length > 0 + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine.teeth); + const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b)); + const nextGroups = edgeLinked + ? unlinkAdjacentTeeth(currentGroups, a, b) + : linkAdjacentTeeth(currentGroups, a, b); + if (!nextGroups) return; + updateActiveLine((line) => ({ + ...line, + toothSelectionGroups: nextGroups, + teeth: deriveTeethFromGroups(nextGroups), + toothProsthesis: pruneToothProsthesisForGroups( + line.toothProsthesis, + line.clientId, + nextGroups, + ), + })); + }} + /> + + {prosthesisRows.length > 0 ? ( +
+

+ {tTreatment('prosthesisTypesTitle')} +

+ {prosthesisRows.every((r) => r.kind === 'single') && + prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? ( + + ) : null} +
+ {prosthesisRows.map((row) => { + const current = + activeLine.toothProsthesis.find( + (tp) => + tp.selectionGroupId === row.groupId && + (row.teeth as string[]).includes(tp.tooth), + )?.prosthesisTypeCode ?? + activeLine.toothProsthesis.find((tp) => + (row.teeth as string[]).includes(tp.tooth), + )?.prosthesisTypeCode ?? + ''; + return ( + + ); + })} +
+
+ ) : null} + +