From 6d90787c1308c891bb869908c4c9207cac7b8043 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 00:06:21 +0330 Subject: [PATCH 1/8] improvement: FDI tooth chart overhauled. --- .cursor/rules/treatment-workspace.mdc | 2 +- .cursor/skills/treatment-workspace/SKILL.md | 7 +- AGENTS.md | 2 +- frontend/messages/en.json | 6 +- frontend/messages/fa.json | 6 +- frontend/messages/nl.json | 6 +- frontend/public/FDI_11.svg | 39 +++ frontend/public/FDI_12.svg | 33 ++ frontend/public/FDI_13.svg | 39 +++ frontend/public/FDI_14.svg | 39 +++ frontend/public/FDI_15.svg | 39 +++ frontend/public/FDI_16.svg | 1 + frontend/public/FDI_17.svg | 27 ++ frontend/public/FDI_18.svg | 27 ++ frontend/public/FDI_21.svg | 39 +++ frontend/public/FDI_22.svg | 33 ++ frontend/public/FDI_23.svg | 39 +++ frontend/public/FDI_24.svg | 39 +++ frontend/public/FDI_25.svg | 39 +++ frontend/public/FDI_26.svg | 1 + frontend/public/FDI_27.svg | 27 ++ frontend/public/FDI_28.svg | 27 ++ frontend/public/FDI_31.svg | 27 ++ frontend/public/FDI_32.svg | 27 ++ frontend/public/FDI_33.svg | 27 ++ frontend/public/FDI_34.svg | 27 ++ frontend/public/FDI_35.svg | 27 ++ frontend/public/FDI_36.svg | 27 ++ frontend/public/FDI_37.svg | 27 ++ frontend/public/FDI_38.svg | 27 ++ frontend/public/FDI_41.svg | 27 ++ frontend/public/FDI_42.svg | 27 ++ frontend/public/FDI_43.svg | 27 ++ frontend/public/FDI_44.svg | 27 ++ frontend/public/FDI_45.svg | 27 ++ frontend/public/FDI_46.svg | 27 ++ frontend/public/FDI_47.svg | 27 ++ frontend/public/FDI_48.svg | 27 ++ frontend/scripts/extract-tooth-svgs.mjs | 54 ++++ .../src/components/treatment/fdiToothScale.ts | 75 +++++ .../treatment/realisticToothAssets.ts | 261 +++++++++++++++ .../treatment/toothSelectionGroups.ts | 118 ++++++- .../components/ui/lab/CaseSheetFdiChart.tsx | 8 +- .../components/ui/treatment/FdiToothChart.tsx | 306 ++++++++++++------ .../components/ui/treatment/ToothGlyph.tsx | 130 +++++++- .../ui/treatment/TreatmentWorkspace.tsx | 56 ++++ frontend/src/styles/globals.css | 28 ++ 47 files changed, 1839 insertions(+), 146 deletions(-) create mode 100644 frontend/public/FDI_11.svg create mode 100644 frontend/public/FDI_12.svg create mode 100644 frontend/public/FDI_13.svg create mode 100644 frontend/public/FDI_14.svg create mode 100644 frontend/public/FDI_15.svg create mode 100644 frontend/public/FDI_16.svg create mode 100644 frontend/public/FDI_17.svg create mode 100644 frontend/public/FDI_18.svg create mode 100644 frontend/public/FDI_21.svg create mode 100644 frontend/public/FDI_22.svg create mode 100644 frontend/public/FDI_23.svg create mode 100644 frontend/public/FDI_24.svg create mode 100644 frontend/public/FDI_25.svg create mode 100644 frontend/public/FDI_26.svg create mode 100644 frontend/public/FDI_27.svg create mode 100644 frontend/public/FDI_28.svg create mode 100644 frontend/public/FDI_31.svg create mode 100644 frontend/public/FDI_32.svg create mode 100644 frontend/public/FDI_33.svg create mode 100644 frontend/public/FDI_34.svg create mode 100644 frontend/public/FDI_35.svg create mode 100644 frontend/public/FDI_36.svg create mode 100644 frontend/public/FDI_37.svg create mode 100644 frontend/public/FDI_38.svg create mode 100644 frontend/public/FDI_41.svg create mode 100644 frontend/public/FDI_42.svg create mode 100644 frontend/public/FDI_43.svg create mode 100644 frontend/public/FDI_44.svg create mode 100644 frontend/public/FDI_45.svg create mode 100644 frontend/public/FDI_46.svg create mode 100644 frontend/public/FDI_47.svg create mode 100644 frontend/public/FDI_48.svg create mode 100644 frontend/scripts/extract-tooth-svgs.mjs create mode 100644 frontend/src/components/treatment/fdiToothScale.ts create mode 100644 frontend/src/components/treatment/realisticToothAssets.ts diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index a22441a..c8fb388 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -9,7 +9,7 @@ alwaysApply: false - **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. +- **Tooth groups:** Adjacent selected teeth show an empty circle between them — click to connect / filled to disconnect; Shift+range selects only (empty circles; overlap absorbs as singles; midline OK). Plain click select/deselect (deselect splits bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send. - **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. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 2b37d1a..984b8ef 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -40,7 +40,7 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx | Step | UI | Notes | |------|-----|--------| -| **Teeth** | `FdiToothChart` | Shift+click connected bridge (union if overlap); click cuts tooth from bridge as selected single | +| **Teeth** | `FdiToothChart` | Neighbor circles connect/disconnect; Shift+range selects (empty circles); plain click select/deselect | | **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`. | @@ -56,8 +56,9 @@ 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. +- **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. diff --git a/AGENTS.md b/AGENTS.md index 6c672fd..f84d92d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ frontend/src/ - 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. +- **Tooth selection:** 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). **Treatment lab rules (quick ref):** diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 760b32c..7370ecd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -830,8 +830,12 @@ "entryStepNext": "Next", "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).", "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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 17ea3d7..0de9a92 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -824,8 +824,12 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل می‌سازد. کلیک روی دندان داخل پل آن را جدا می‌کند (همچنان انتخاب‌شده به‌صورت تک)؛ انتها یک پل می‌ماند، وسط به دو پل تقسیم می‌شود.", + "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).", "toothConnectedHint": "انتخاب متصل", + "toothLinkHint": "اتصال این دندان‌ها", + "toothUnlinkHint": "قطع اتصال این دندان‌ها", + "toothLinkAria": "اتصال دندان‌های {a} و {b}", + "toothUnlinkAria": "قطع اتصال دندان‌های {a} و {b}", "toothConnectedSuffix": " (متصل)", "connectedBadge": "متصل", "prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index bc00c67..a7ee63f 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -823,8 +823,12 @@ "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).", "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", diff --git a/frontend/public/FDI_11.svg b/frontend/public/FDI_11.svg new file mode 100644 index 0000000..a5d3918 --- /dev/null +++ b/frontend/public/FDI_11.svg @@ -0,0 +1,39 @@ + + + FDI 11 maxillary central incisor + Buccal-view tooth asset with independently selectable crown and root groups. + + + + + + + + + + + + diff --git a/frontend/public/FDI_12.svg b/frontend/public/FDI_12.svg new file mode 100644 index 0000000..f554838 --- /dev/null +++ b/frontend/public/FDI_12.svg @@ -0,0 +1,33 @@ + + + FDI 12 + Selectable crown and root SVG tooth asset. + + + + + + + + + + diff --git a/frontend/public/FDI_13.svg b/frontend/public/FDI_13.svg new file mode 100644 index 0000000..d75f0fc --- /dev/null +++ b/frontend/public/FDI_13.svg @@ -0,0 +1,39 @@ + + + FDI 13 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_14.svg b/frontend/public/FDI_14.svg new file mode 100644 index 0000000..320c39f --- /dev/null +++ b/frontend/public/FDI_14.svg @@ -0,0 +1,39 @@ + + + FDI 14 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_15.svg b/frontend/public/FDI_15.svg new file mode 100644 index 0000000..dc8f1a6 --- /dev/null +++ b/frontend/public/FDI_15.svg @@ -0,0 +1,39 @@ + + + FDI 15 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_16.svg b/frontend/public/FDI_16.svg new file mode 100644 index 0000000..ad6312c --- /dev/null +++ b/frontend/public/FDI_16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/FDI_17.svg b/frontend/public/FDI_17.svg new file mode 100644 index 0000000..542cf47 --- /dev/null +++ b/frontend/public/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/public/FDI_18.svg b/frontend/public/FDI_18.svg new file mode 100644 index 0000000..b34bd4f --- /dev/null +++ b/frontend/public/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/public/FDI_21.svg b/frontend/public/FDI_21.svg new file mode 100644 index 0000000..dbaf83b --- /dev/null +++ b/frontend/public/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/public/FDI_22.svg b/frontend/public/FDI_22.svg new file mode 100644 index 0000000..ab560b8 --- /dev/null +++ b/frontend/public/FDI_22.svg @@ -0,0 +1,33 @@ + + + FDI 22 + Selectable crown and root SVG tooth asset. + + + + + + + + + + diff --git a/frontend/public/FDI_23.svg b/frontend/public/FDI_23.svg new file mode 100644 index 0000000..fd37ff7 --- /dev/null +++ b/frontend/public/FDI_23.svg @@ -0,0 +1,39 @@ + + + FDI 23 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_24.svg b/frontend/public/FDI_24.svg new file mode 100644 index 0000000..f526384 --- /dev/null +++ b/frontend/public/FDI_24.svg @@ -0,0 +1,39 @@ + + + FDI 24 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_25.svg b/frontend/public/FDI_25.svg new file mode 100644 index 0000000..99a9294 --- /dev/null +++ b/frontend/public/FDI_25.svg @@ -0,0 +1,39 @@ + + + FDI 25 + Selectable crown and root SVG tooth asset. + + + + + + + + + + + + diff --git a/frontend/public/FDI_26.svg b/frontend/public/FDI_26.svg new file mode 100644 index 0000000..37f1315 --- /dev/null +++ b/frontend/public/FDI_26.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/FDI_27.svg b/frontend/public/FDI_27.svg new file mode 100644 index 0000000..e6c173e --- /dev/null +++ b/frontend/public/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/public/FDI_28.svg b/frontend/public/FDI_28.svg new file mode 100644 index 0000000..94de056 --- /dev/null +++ b/frontend/public/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/public/FDI_31.svg b/frontend/public/FDI_31.svg new file mode 100644 index 0000000..083a845 --- /dev/null +++ b/frontend/public/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/public/FDI_32.svg b/frontend/public/FDI_32.svg new file mode 100644 index 0000000..842d975 --- /dev/null +++ b/frontend/public/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/public/FDI_33.svg b/frontend/public/FDI_33.svg new file mode 100644 index 0000000..657e30e --- /dev/null +++ b/frontend/public/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/public/FDI_34.svg b/frontend/public/FDI_34.svg new file mode 100644 index 0000000..e52f6c6 --- /dev/null +++ b/frontend/public/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/public/FDI_35.svg b/frontend/public/FDI_35.svg new file mode 100644 index 0000000..07b5992 --- /dev/null +++ b/frontend/public/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/public/FDI_36.svg b/frontend/public/FDI_36.svg new file mode 100644 index 0000000..7eb4fc4 --- /dev/null +++ b/frontend/public/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/public/FDI_37.svg b/frontend/public/FDI_37.svg new file mode 100644 index 0000000..c36e27a --- /dev/null +++ b/frontend/public/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/public/FDI_38.svg b/frontend/public/FDI_38.svg new file mode 100644 index 0000000..5288756 --- /dev/null +++ b/frontend/public/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/public/FDI_41.svg b/frontend/public/FDI_41.svg new file mode 100644 index 0000000..8c87e2f --- /dev/null +++ b/frontend/public/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/public/FDI_42.svg b/frontend/public/FDI_42.svg new file mode 100644 index 0000000..48f7d1c --- /dev/null +++ b/frontend/public/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/public/FDI_43.svg b/frontend/public/FDI_43.svg new file mode 100644 index 0000000..e83c361 --- /dev/null +++ b/frontend/public/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/public/FDI_44.svg b/frontend/public/FDI_44.svg new file mode 100644 index 0000000..0fcea54 --- /dev/null +++ b/frontend/public/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/public/FDI_45.svg b/frontend/public/FDI_45.svg new file mode 100644 index 0000000..86ed8f0 --- /dev/null +++ b/frontend/public/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/public/FDI_46.svg b/frontend/public/FDI_46.svg new file mode 100644 index 0000000..1c7d968 --- /dev/null +++ b/frontend/public/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/public/FDI_47.svg b/frontend/public/FDI_47.svg new file mode 100644 index 0000000..721db18 --- /dev/null +++ b/frontend/public/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/public/FDI_48.svg b/frontend/public/FDI_48.svg new file mode 100644 index 0000000..59c4e66 --- /dev/null +++ b/frontend/public/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/scripts/extract-tooth-svgs.mjs b/frontend/scripts/extract-tooth-svgs.mjs new file mode 100644 index 0000000..bd4726c --- /dev/null +++ b/frontend/scripts/extract-tooth-svgs.mjs @@ -0,0 +1,54 @@ +/** + * Regenerates realisticToothAssets.ts from FDI_*.svg files in public/. + * Usage (from frontend/): node scripts/extract-tooth-svgs.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const dir = path.resolve('public'); +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(/]*>/)?.[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 public/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> = ${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/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/realisticToothAssets.ts b/frontend/src/components/treatment/realisticToothAssets.ts new file mode 100644 index 0000000..34fe199 --- /dev/null +++ b/frontend/src/components/treatment/realisticToothAssets.ts @@ -0,0 +1,261 @@ +import type { FdiToothId } from '@/types/treatment'; + +/** + * Realistic buccal-view assets from public/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/ui/lab/CaseSheetFdiChart.tsx b/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx index 2fae32b..fa52558 100644 --- a/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx +++ b/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx @@ -127,9 +127,9 @@ function ArchRow({ {linkToNext ? ( {fdi} - {isConnected && isSelected ? ( - + {linkToNext ? ( + ) : null} ); diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index e621fd6..473cf9d 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -3,27 +3,63 @@ import { useId, type CSSProperties, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta'; +import { + getToothColumnWidthRem, + getToothGlyphWidthRem, +} from '@/components/treatment/fdiToothScale'; +import { getRealisticToothAsset } from '@/components/treatment/realisticToothAssets'; +import { toothEdgeKey } from '@/components/treatment/toothSelectionGroups'; import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph'; import type { FdiToothId } from '@/types/treatment'; const TOOTH_NUMBER_GAP = 'mt-1'; - -/** Six teeth each side of the midline (anteriors): slightly larger glyph + wrapper. */ -const MIDLINE_SIX = new Set([ - '13', '12', '11', '21', '22', '23', - '43', '42', '41', '31', '32', '33', -]); +/** Extra crown↔number space for realistic SVGs (outside arch transform so it is not cancelled). */ +const REALISTIC_NUMBER_GAP = '2mm'; +/** Tight interproximal gap between tooth columns. */ +const TOOTH_GAP = 'gap-x-px'; +/** Fixed row cell height — must fit tallest scaled canine/root. */ +const TOOTH_CELL_H = 'h-[7.25rem]'; function quadrantMirrored(fdi: FdiToothId): boolean { const q = fdi[0]; return q === '2' || q === '3'; } +/** Cartoon glyph height factor vs width (realistic assets use aspect ratio from viewBox). */ +function cartoonHeightRem(fdi: FdiToothId, widthRem: number): number { + const kind = getToothShapeKind(fdi); + const factor = kind === 'canine' ? 2.85 : kind === 'molar' ? 2.45 : 2.65; + return Number((widthRem * factor).toFixed(3)); +} + +function toothSize(fdi: FdiToothId): { + columnWidthRem: number; + glyphWidthRem: number; + glyphHeightRem: number | 'auto'; +} { + const glyphWidthRem = getToothGlyphWidthRem(fdi); + const columnWidthRem = getToothColumnWidthRem(fdi); + const realistic = Boolean(getRealisticToothAsset(fdi)); + return { + columnWidthRem, + glyphWidthRem, + glyphHeightRem: realistic ? 'auto' : cartoonHeightRem(fdi, glyphWidthRem), + }; +} + interface FdiToothChartProps { selected: ReadonlySet; - /** Teeth that belong to a connected selection span (dots above/below). */ + /** + * Legacy / read-only: teeth in a connected bridge. + * When `linkedEdges` is omitted, filled marks are shown between adjacent + * selected teeth that both appear in this set. + */ connectedTeeth?: ReadonlySet; + /** Filled connection edges (`toothEdgeKey(a,b)`). Preferred over `connectedTeeth` for edit mode. */ + linkedEdges?: ReadonlySet; onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; + /** Toggle connect/disconnect for an adjacent selected pair. */ + onToggleLink?: (a: FdiToothId, b: FdiToothId) => void; disabled?: boolean; /** Non-interactive display (Cases / connection history). */ readOnly?: boolean; @@ -38,10 +74,52 @@ interface FdiToothChartProps { className?: string; } +/** + * Distal tip for arch look — gentler than pre-SVG angles so realistic glyphs stay readable. + * Positive rotate = clockwise. + */ +const TOOTH_TWEAKS: Partial> = { + '18': { offset: 5, rotate: -7 }, + '17': { offset: 6, rotate: -5 }, + '16': { offset: 6, rotate: -3.5 }, + '15': { offset: 6, rotate: -2 }, + '14': { offset: 5, rotate: -1 }, + '13': { offset: 5.5, rotate: -0.5 }, + '12': { offset: 4, rotate: -0.25 }, + '11': { offset: 5, rotate: 0 }, + '21': { offset: 5, rotate: 0 }, + '22': { offset: 4, rotate: 0.25 }, + '23': { offset: 5.5, rotate: 0.5 }, + '24': { offset: 5, rotate: 1 }, + '25': { offset: 6, rotate: 2 }, + '26': { offset: 6, rotate: 3.5 }, + '27': { offset: 6, rotate: 5 }, + '28': { offset: 5, rotate: 7 }, + + '48': { offset: -5, rotate: -4 }, + '47': { offset: -6, rotate: -3 }, + '46': { offset: -6, rotate: -2 }, + '45': { offset: -6, rotate: -2 }, + '44': { offset: -5, rotate: -1 }, + '43': { offset: -4, rotate: -0.5 }, + '42': { offset: -4, rotate: -0.25 }, + '41': { offset: -3, rotate: 0 }, + '31': { offset: -3, rotate: 0 }, + '32': { offset: -4, rotate: 0.25 }, + '33': { offset: -4, rotate: 0.5 }, + '34': { offset: -5, rotate: 1 }, + '35': { offset: -6, rotate: 2 }, + '36': { offset: -6, rotate: 2 }, + '37': { offset: -6, rotate: 3 }, + '38': { offset: -5, rotate: 4 }, +}; + export function FdiToothChart({ selected, connectedTeeth, + linkedEdges, onToggle, + onToggleLink, disabled, readOnly = false, scale = 1, @@ -52,70 +130,12 @@ export function FdiToothChart({ }: FdiToothChartProps) { const t = useTranslations('treatment'); const uid = useId().replace(/:/g, ''); - const archPeak = 10; + const archPeak = 8; const interactive = !readOnly && Boolean(onToggle); + const linkInteractive = interactive && Boolean(onToggleLink); const isDisabled = disabled || readOnly; - const TOOTH_TWEAKS: Record = { - '18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 }, - '17': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: -8 }, - '16': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: -6 }, - '15': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: -4 }, - '14': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: -2 }, - '13': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: -1 }, - '12': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: -1 }, - '11': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: -0.5 }, - '21': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: 0.5 }, - '22': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: 1 }, - '23': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: 1 }, - '24': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: 2 }, - '25': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: 4 }, - '26': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: 6 }, - '27': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: 8 }, - '28': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: 11 }, - - '48': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: -11 }, - '47': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: -8 }, - '46': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: -6 }, - '45': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: -4 }, - '44': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: -2 }, - '43': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: -1 }, - '42': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: -1 }, - '41': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: -0.5 }, - '31': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: 0.5 }, - '32': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: 1 }, - '33': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: 1 }, - '34': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: 2 }, - '35': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: 4 }, - '36': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: 6 }, - '37': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: 8 }, - '38': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: 11 }, - }; - - const toothSizeClass = (fdi: FdiToothId) => { - const kind = getToothShapeKind(fdi); - const mid = MIDLINE_SIX.has(fdi); - switch (kind) { - case 'incisor': - return mid - ? { wrapper: 'w-[2.1rem]', glyph: 'w-[1.68rem] h-[4.78rem]' } - : { wrapper: 'w-[2rem]', glyph: 'w-[1.6rem] h-[4.6rem]' }; - case 'canine': - return mid - ? { wrapper: 'w-[2.2rem]', glyph: 'w-[1.78rem] h-[5.05rem]' } - : { wrapper: 'w-[2.1rem]', glyph: 'w-[1.7rem] h-[4.9rem]' }; - case 'premolar': - return mid - ? { wrapper: 'w-[2.34rem]', glyph: 'w-[1.93rem] h-[5.02rem]' } - : { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; - case 'molar': - return mid - ? { wrapper: 'w-[2.52rem]', glyph: 'w-[2.12rem] h-[5.1rem]' } - : { wrapper: 'w-[2.45rem]', glyph: 'w-[2.05rem] h-[4.95rem]' }; - default: - return { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' }; - } - }; + const toothSizeClass = (fdi: FdiToothId) => toothSize(fdi); const archOffset = (index: number, count: number, upper?: boolean) => { const center = (count - 1) / 2; @@ -127,7 +147,7 @@ export function FdiToothChart({ const archRotate = (index: number, count: number) => { const center = (count - 1) / 2; const normalized = (index - center) / center; - return normalized * 6; + return normalized * 4; }; const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi]; @@ -144,38 +164,93 @@ export function FdiToothChart({ return accent ? { color: accent } : undefined; }; - /** Dots + thin links — only for Shift-connected spans (not plain singles). */ - const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => { - const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi)); - if (!hasConnected) return null; + const edgeIsLinked = (a: FdiToothId, b: FdiToothId): boolean => { + const key = toothEdgeKey(a, b); + if (linkedEdges) return linkedEdges.has(key); + // Read-only fallback: both teeth marked connected. + return Boolean(connectedTeeth?.has(a) && connectedTeeth?.has(b)); + }; + + /** Circles sit between neighbors, on the crown side (toward FDI numbers). */ + const EdgeRail = ({ teeth }: { teeth: FdiToothId[] }) => { + const anyVisible = teeth.some((fdi, i) => { + const next = teeth[i + 1]; + if (!next || !selected.has(fdi) || !selected.has(next)) return false; + const linked = edgeIsLinked(fdi, next); + return linkInteractive || linked; + }); + if (!anyVisible) return null; return (
{teeth.map((fdi, i) => { const size = toothSizeClass(fdi); - const isConnected = Boolean(connectedTeeth?.has(fdi)); + const prev = i > 0 ? teeth[i - 1] : undefined; const next = teeth[i + 1]; - const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next)); + const showEdge = Boolean(next && selected.has(fdi) && selected.has(next)); + const linked = showEdge ? edgeIsLinked(fdi, next!) : false; + const renderEdge = showEdge && (linkInteractive || linked); + // Narrow link across this column when both neighboring edges are filled. + const bridgeLine = Boolean( + prev && + next && + selected.has(prev) && + selected.has(fdi) && + selected.has(next) && + edgeIsLinked(prev, fdi) && + edgeIsLinked(fdi, next), + ); + return (
- {linkToNext ? ( + {bridgeLine ? ( ) : null} - {isConnected ? ( - - ) : ( - - )} + {renderEdge ? ( + linkInteractive ? ( +
); })} @@ -184,20 +259,21 @@ export function FdiToothChart({ }; const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => ( -
- {upper ? : null} -
+
+ {/* Lower: crowns face numbers above → rail above teeth */} + {!upper ? : null} +
{teeth.map((fdi, i) => { const kind = getToothShapeKind(fdi); const gid = `${uid}-g-${fdi}-${i}`; const isSel = selected.has(fdi); - const isConnected = Boolean(connectedTeeth?.has(fdi)); const size = toothSizeClass(fdi); const tweak = TOOTH_TWEAKS[fdi]; const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper); const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length); const alignItems = upper ? 'items-end' : 'items-start'; const accent = toothAccent(fdi); + const realistic = Boolean(getRealisticToothAsset(fdi)); const glyph = ( ); return ( -
-
+
+ {!upper && realistic ? ( +
+ ) : null} +
{interactive ? (
)}
+ {upper && realistic ? ( +
+ ) : null}
); })}
- {!upper ? : null} + {/* Upper: crowns face numbers below → rail below teeth */} + {upper ? : null}
); @@ -279,15 +367,18 @@ export function FdiToothChart({ />
-
+
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => { const size = toothSizeClass(fdi); const isSel = selected.has(fdi); return ( {fdi} @@ -298,15 +389,18 @@ export function FdiToothChart({
-
+
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => { const size = toothSizeClass(fdi); const isSel = selected.has(fdi); return ( {fdi} diff --git a/frontend/src/components/ui/treatment/ToothGlyph.tsx b/frontend/src/components/ui/treatment/ToothGlyph.tsx index 2e0f7be..f4ea2ec 100644 --- a/frontend/src/components/ui/treatment/ToothGlyph.tsx +++ b/frontend/src/components/ui/treatment/ToothGlyph.tsx @@ -1,9 +1,13 @@ 'use client'; -import { memo, type ReactNode } from 'react'; +import { memo, type CSSProperties, type ReactNode } from 'react'; import type { FdiToothId } from '@/types/treatment'; import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; import { getToothPathModel, type ToothPathModel } from '@/components/treatment/toothPathModel'; +import { + getRealisticToothAsset, + type RealisticToothAsset, +} from '@/components/treatment/realisticToothAssets'; interface ToothGlyphProps { fdi: FdiToothId; @@ -14,6 +18,7 @@ interface ToothGlyphProps { mirrored?: boolean; upsideDown?: boolean; className?: string; + style?: CSSProperties; /** When set, selected tooth glow + fill use this color instead of primary. */ accentColor?: string; } @@ -34,19 +39,40 @@ function rgbaFromHex(hex: string, alpha: number): string { return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; } +function mixHex(hex: string, toward: string, amount: number): string { + const a = hexToRgb(hex); + const b = hexToRgb(toward); + if (!a || !b) return hex; + const t = Math.min(1, Math.max(0, amount)); + const r = Math.round(a.r + (b.r - a.r) * t); + const g = Math.round(a.g + (b.g - a.g) * t); + const bl = Math.round(a.b + (b.b - a.b) * t); + return `#${[r, g, bl].map((n) => n.toString(16).padStart(2, '0')).join('')}`; +} + function filledSilhouette( - model: ToothPathModel, + model: Pick, crownFill: string, rootFill: string, stroke: string, strokeW: number, + rootsFirst = false, ): ReactNode { - return ( + const crown = ( + + ); + const roots = model.roots.map((d, i) => ( + + )); + return rootsFirst ? ( <> - - {model.roots.map((d, i) => ( - - ))} + {roots} + {crown} + + ) : ( + <> + {crown} + {roots} ); } @@ -74,6 +100,27 @@ function renderClinicalFill( ); } +function renderRealisticFill( + asset: RealisticToothAsset, + selected: boolean, + crownGradId: string, + rootGradId: string, + accentColor?: string, +): ReactNode { + const stroke = selected + ? (accentColor ?? 'var(--color-primary)') + : 'var(--tooth-stroke)'; + const strokeW = selected ? 1.05 : 0.55; + return filledSilhouette( + asset, + `url(#${crownGradId})`, + `url(#${rootGradId})`, + stroke, + strokeW, + true, + ); +} + /** FDI chart tooth: clinical gradient + roots only. */ export const ToothGlyph = memo(function ToothGlyph({ fdi, @@ -85,17 +132,78 @@ export const ToothGlyph = memo(function ToothGlyph({ upsideDown, className = 'w-8 h-[4.85rem]', accentColor, + style, }: ToothGlyphProps) { - const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper); + const realistic = getRealisticToothAsset(fdi); const filter = selected ? accentColor ? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})` : 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' - : undefined; + : 'drop-shadow(0 1px 1.5px rgba(15, 23, 42, 0.18))'; + const svgStyle: CSSProperties = { filter, ...style }; + + if (realistic) { + const crownGradId = `${gradientId}-crown`; + const rootGradId = `${gradientId}-root`; + // Align crown edge toward the FDI numbers (upper: bottom of box, lower: top). + const preserveAspectRatio = upper ? 'xMidYMax meet' : 'xMidYMin meet'; + + const crownStops = selected + ? accentColor + ? { hi: '#ffffff', mid: accentColor, shade: mixHex(accentColor, '#0f172a', 0.22) } + : { hi: '#ffffff', mid: '#5eead4', shade: '#0e7490' } + : { + hi: 'var(--tooth-crown-hi)', + mid: 'var(--tooth-crown-mid)', + shade: 'var(--tooth-crown-shade)', + }; + + const rootStops = selected + ? accentColor + ? { + hi: mixHex(accentColor, '#ffffff', 0.2), + mid: mixHex(accentColor, '#0f172a', 0.12), + shade: mixHex(accentColor, '#0f172a', 0.32), + } + : { hi: '#99f6e4', mid: '#14b8a6', shade: '#0f766e' } + : { + hi: 'var(--tooth-root-hi)', + mid: 'var(--tooth-root-mid)', + shade: 'var(--tooth-root-shade)', + }; + + return ( + + + + + + + + + + + + + + + {renderRealisticFill(realistic, selected, crownGradId, rootGradId, accentColor)} + + + ); + } + + const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper); if (!model) { return ( - + ? @@ -121,7 +229,7 @@ export const ToothGlyph = memo(function ToothGlyph({ diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 811e118..f9dccf7 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -45,8 +45,12 @@ import { connectedTeethSet, deriveTeethFromGroups, groupsFromFlatTeeth, + linkAdjacentTeeth, + linkedEdgesFromGroups, pruneToothProsthesisForGroups, + toothEdgeKey, toggleToothInGroups, + unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; @@ -614,6 +618,10 @@ export function TreatmentWorkspace({ () => connectedTeethSet(activeDetail?.toothSelectionGroups ?? []), [activeDetail?.toothSelectionGroups], ); + const linkedToothEdges = useMemo( + () => linkedEdgesFromGroups(activeDetail?.toothSelectionGroups ?? []), + [activeDetail?.toothSelectionGroups], + ); const rangeAnchorRef = useRef(null); const wholePlanTeethSet = useMemo(() => { @@ -2016,6 +2024,7 @@ export function TreatmentWorkspace({ {entryStep === 'teeth' ? ( { + if ( + !canEditTreatmentForDay || + isDetailLocked(activeDetail) || + showWholeTreatmentPlan || + !activeDetailId + ) { + return; + } + const detail = details.find((d) => d.clientId === activeDetailId); + if (!detail) return; + const currentGroups = + detail.toothSelectionGroups.length > 0 + ? detail.toothSelectionGroups + : groupsFromFlatTeeth(detail.teeth); + const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b)); + const nextGroups = edgeLinked + ? unlinkAdjacentTeeth(currentGroups, a, b) + : linkAdjacentTeeth(currentGroups, a, b); + if (!nextGroups) return; + + setDetails((prev) => + prev.map((d) => + d.clientId !== activeDetailId + ? d + : { + ...d, + toothSelectionGroups: nextGroups, + teeth: deriveTeethFromGroups(nextGroups), + }, + ), + ); + + setLabCaseDrafts((prev) => + prev.map((lc) => { + if (lc.detailClientId !== activeDetailId) return lc; + return { + ...lc, + toothProsthesis: pruneToothProsthesisForGroups( + lc.toothProsthesis, + activeDetailId, + nextGroups, + ), + }; + }), + ); + }} disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)} /> ) : null} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 970d26f..94d04ae 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -57,6 +57,16 @@ --color-primary-soft: rgba(0, 156, 174, 0.16); --color-icon: #e1bc72; + /* Realistic tooth: white–blue crown, soft pink root */ + --tooth-crown-hi: #ffffff; + --tooth-crown-mid: #e0f2fe; + --tooth-crown-shade: #93c5fd; + --tooth-root-hi: #f6efe6; + --tooth-root-mid: #e4d4bf; + --tooth-root-shade: #c9b396; + --tooth-stroke: #94a3b8; + --tooth-hover: #cfe0f5; + --web-opacity: 0.12; --web-line: rgba(0, 188, 255, 0.32); --web-glow: rgba(0, 188, 255, 0.42); @@ -114,6 +124,15 @@ --color-primary-contrast: #001117; --color-primary-soft: rgba(9, 169, 188, 0.2); --color-icon: #e1bc72; + + --tooth-crown-hi: #ffffff; + --tooth-crown-mid: #dbeafe; + --tooth-crown-shade: #7dd3fc; + --tooth-root-hi: #f3ebe0; + --tooth-root-mid: #dcc9ae; + --tooth-root-shade: #bba280; + --tooth-stroke: #94a3b8; + --tooth-hover: #bdd0e0; } /* Default theme = dark */ @@ -173,6 +192,15 @@ --color-primary-soft: rgba(9, 169, 188, 0.2); --color-icon: #f3bb4b; + --tooth-crown-hi: #ffffff; + --tooth-crown-mid: #dbeafe; + --tooth-crown-shade: #7dd3fc; + --tooth-root-hi: #f3ebe0; + --tooth-root-mid: #dcc9ae; + --tooth-root-shade: #bba280; + --tooth-stroke: #94a3b8; + --tooth-hover: #bdd0e0; + --web-opacity: 0.14; --web-line: rgba(59, 153, 220, 0.34); --web-line-strong: rgba(73, 214, 255, 0.78); -- 2.53.0.windows.1 From d6958b2e48f7addeea92d39eeffe3544ba0ed49c Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 00:39:31 +0330 Subject: [PATCH 2/8] bugfix: the flow for trial and accept invitation (org + staff)is now unified. all navigate to dshboard if succesfull. --- .cursor/rules/dyolink-overview.mdc | 2 +- .cursor/rules/lab-case-share-link.mdc | 2 +- .cursor/rules/post-auth-navigation.mdc | 16 +++++++++++++ .cursor/skills/lab-case-share-link/SKILL.md | 11 +++++---- AGENTS.md | 2 +- backend/prisma/wipe-app-data.ts | 4 ++++ frontend/messages/en.json | 5 ++-- frontend/messages/fa.json | 5 ++-- frontend/messages/nl.json | 5 ++-- .../[locale]/(public)/accept-invite/page.tsx | 24 +++++++++++++++---- .../accept-organization-invite/page.tsx | 20 +++++++++++++--- .../src/app/[locale]/(public)/login/page.tsx | 17 ++++--------- .../app/[locale]/(public)/register/page.tsx | 12 +++++++++- frontend/src/lib/auth/postAuthRedirect.ts | 17 +++++++++++++ frontend/src/lib/hooks/useAuth.tsx | 16 ++++++------- .../lib/hooks/useEnterAppWhenAuthenticated.ts | 23 ++++++++++++++++++ 16 files changed, 138 insertions(+), 43 deletions(-) create mode 100644 .cursor/rules/post-auth-navigation.mdc create mode 100644 frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index d3d47c2..c484b78 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -36,7 +36,7 @@ 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`. ## Notifications (inbox + live tabs) 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/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/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/AGENTS.md b/AGENTS.md index f84d92d..0b93f86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ frontend/src/ - 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`. 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/frontend/messages/en.json b/frontend/messages/en.json index 7370ecd..26ab69e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 0de9a92..c4cb5fa 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "نام سازمان", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a7ee63f..d58539e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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", 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..7745e07 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -16,6 +16,8 @@ import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDeta 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 +35,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 +140,7 @@ function AcceptOrganizationInviteContent() { setError(''); setSuccess(''); setSubmitting(true); + let accepted = false; try { await organizationApi.acceptInvite({ token, @@ -146,10 +150,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); } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index 1cd8f93..35a6cc1 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'; @@ -16,10 +15,8 @@ import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; 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 +29,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(null); const [savedEmail] = useState(() => getRememberedEmail()); @@ -55,12 +51,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, diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 36ec6ae..6d17c13 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -9,6 +9,7 @@ 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 { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; @@ -38,9 +39,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(null); + useEnterAppWhenAuthenticated(); const registerSchema = useMemo( () => @@ -121,6 +123,14 @@ export default function RegisterPage() { hide: t('hidePassword'), }; + if (!isAuthReady) { + return ( +
+

{tCommon('loading')}

+
+ ); + } + return ( void, + organizationCount: number, +) { + if (organizationCount > 1) return; + replace(appPathAfterAuth()); +} diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index a014829..d29cd61 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -18,7 +18,7 @@ import { } from '@/lib/auth/proactiveRefresh'; import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents'; import { asApiError, legacyStatusCode, type ApiError } from '@/types/api'; -import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect'; +import { appPathAfterAuth } from '@/lib/auth/postAuthRedirect'; function toApiError(err: unknown): ApiError { return ( @@ -50,7 +50,11 @@ interface AuthContextType { organizationEmail: string, organizationType: 'CLINIC' | 'LAB' ) => Promise; - login: (email: string, password: string, rememberMe?: boolean) => Promise; + login: ( + email: string, + password: string, + rememberMe?: boolean, + ) => Promise<{ organizations: Organization[] }>; logout: () => Promise; selectOrganization: (orgId: string) => Promise; createOrganization: ( @@ -337,6 +341,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { router.push('/select-organization'); } + return { organizations: orgs }; } catch (err: any) { setApiError(toApiError(err)); throw err; @@ -388,12 +393,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { plan: (organization as { plan?: Organization['plan'] }).plan, }); - const redirectPath = consumeAuthRedirect(); - if (redirectPath) { - router.push(redirectPath); - } else { - router.push('/today'); - } + router.push(appPathAfterAuth()); } catch (err: any) { setApiError(toApiError(err)); diff --git a/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts b/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts new file mode 100644 index 0000000..3377318 --- /dev/null +++ b/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from '@/i18n/navigation'; +import { appPathAfterAuth } from '@/lib/auth/postAuthRedirect'; +import { useAuth } from '@/lib/hooks/useAuth'; + +/** + * When a session already exists (or was just created on this page), enter the app. + * Use on /login and /register only — not on invite pages, where a logged-in visitor + * must still be able to finish accept before navigating. + */ +export function useEnterAppWhenAuthenticated() { + const { user, isAuthReady, isLoading, organizations, currentOrganization } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isAuthReady || !user || isLoading) return; + const orgReady = organizations.length <= 1 || currentOrganization; + if (!orgReady) return; + router.push(appPathAfterAuth()); + }, [isAuthReady, user, isLoading, organizations, currentOrganization, router]); +} -- 2.53.0.windows.1 From 80167c622c0eebb9eac1235e9f37aec591095b79 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 01:43:50 +0330 Subject: [PATCH 3/8] bugfix: appointment hours now use the client timezone on UTC servers. Logical API errors throw stable codes so users see translated messages instead of a generic bad request. Co-authored-by: Cursor --- .cursor/rules/api-errors-i18n.mdc | 1 + .cursor/rules/appointments.mdc | 1 + .cursor/rules/dyolink-overview.mdc | 4 + .cursor/skills/api-errors/SKILL.md | 10 +- AGENTS.md | 2 +- backend/src/common/errors/error-codes.ts | 113 +++++++++++++++++- .../common/errors/http-exception.filter.ts | 66 +--------- .../errors/validation-exception.factory.ts | 14 +-- backend/src/common/working-hours.ts | 11 +- backend/src/common/zoned-civil-time.spec.ts | 29 +++++ backend/src/common/zoned-civil-time.ts | 55 +++++++++ .../appointments/appointments.service.ts | 82 +++++++------ .../dto/column-providers-query.dto.ts | 3 +- .../dto/create-appointment.dto.ts | 13 +- backend/src/modules/cases/cases.service.ts | 35 +++--- .../modules/cases/lab-case-access.service.ts | 3 +- .../lab-case-comments.service.ts | 24 ++-- .../lab-case-activity.service.ts | 18 +-- .../organization/organization.service.ts | 89 +++++++------- .../src/modules/patients/patients.service.ts | 8 +- .../prosthesis-catalog.service.ts | 5 +- .../staff/dto/upsert-working-hours.dto.ts | 9 ++ .../staff/staff-working-hours.service.ts | 68 +++++------ backend/src/modules/staff/staff.service.ts | 98 +++++++-------- backend/src/modules/tasks/tasks.service.ts | 29 +++-- backend/src/modules/today/today.service.ts | 14 +-- .../treatment-catalog.service.ts | 10 +- .../lab-case-send.validation.spec.ts | 3 +- .../treatments/lab-case-send.validation.ts | 7 +- .../modules/treatments/treatments.service.ts | 81 +++++++------ frontend/messages/en.json | 95 +++++++++++++++ frontend/messages/fa.json | 95 +++++++++++++++ frontend/messages/nl.json | 95 +++++++++++++++ .../ui/treatment/LabCasesDispatchPanel.tsx | 4 +- frontend/src/lib/api/account.ts | 6 +- frontend/src/lib/api/appointments.ts | 11 +- frontend/src/lib/api/staff.ts | 6 +- frontend/src/lib/i18n/clientTimeZone.ts | 8 ++ 38 files changed, 833 insertions(+), 392 deletions(-) create mode 100644 backend/src/common/zoned-civil-time.spec.ts create mode 100644 backend/src/common/zoned-civil-time.ts create mode 100644 frontend/src/lib/i18n/clientTimeZone.ts 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..34ed6d0 100644 --- a/.cursor/rules/appointments.mdc +++ b/.cursor/rules/appointments.mdc @@ -10,5 +10,6 @@ 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`). - 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 c484b78..bb11234 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -38,6 +38,10 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo 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) Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`. 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/AGENTS.md b/AGENTS.md index 0b93f86..9119124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ frontend/src/ - **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`. -**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. 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`. diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 231de83..9eb88ad 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -57,14 +57,121 @@ 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', + LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND', + + 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/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..6c6ff56 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,19 +325,19 @@ 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); } } @@ -345,20 +347,25 @@ export class AppointmentsService { select: { id: true }, }); if (!patient) { - throw new NotFoundException('Patient not found'); + 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.service.ts b/backend/src/modules/cases/cases.service.ts index 94976fc..63074dd 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -1,9 +1,8 @@ import { - BadRequestException, - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { createReadStream, existsSync } from 'fs'; import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -103,7 +102,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; } @@ -300,7 +299,7 @@ export class CasesService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return { @@ -327,7 +326,7 @@ export class CasesService { }); 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) }; @@ -356,11 +355,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 { @@ -389,7 +388,7 @@ export class CasesService { }); if (!existing) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } await this.prisma.labCase.update({ @@ -440,7 +439,7 @@ export class CasesService { }); if (!existing) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } const externalCode = @@ -510,7 +509,7 @@ export class CasesService { }); 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 +529,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 +576,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,7 +584,7 @@ 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; @@ -874,27 +873,27 @@ export class CasesService { 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/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 50c18d1..6a8f1aa 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'; @@ -230,7 +229,7 @@ export class LabCaseAccessService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return labCase; 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..ee22a07 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,8 +1,8 @@ import { - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; @@ -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); } } @@ -296,7 +296,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.getLabMembership(actorUserId, labOrganizationId); @@ -304,7 +304,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 +321,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 +340,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 +360,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 +375,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 +383,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 { diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index d19bd94..cdd6411 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -1,8 +1,8 @@ import { - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { LabCaseActivityType, LabCaseTabReadTarget, @@ -49,7 +49,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 +224,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 +454,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 +468,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({ @@ -491,7 +491,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 +513,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 +536,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/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..562e910 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'; @@ -77,7 +77,7 @@ export class PatientsService { }); if (!patient) { - throw new NotFoundException('Patient not found'); + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } return { success: true, data: patient }; @@ -162,7 +162,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 +240,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/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 9a84300..994cbe7 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -1,9 +1,8 @@ import { - BadRequestException, - ForbiddenException, + HttpStatus, Injectable, - NotFoundException, } from '@nestjs/common'; +import { AppException, ErrorCode } from '../../common/errors'; import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; @@ -48,7 +47,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; } @@ -118,7 +117,7 @@ export class TasksService { }); if (!labCase) { - throw new NotFoundException('Case not found'); + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); } return this.listTasksForLabCaseId(labCaseId, localeInput); @@ -176,7 +175,7 @@ export class TasksService { }); if (!target?.labCase.sentAt) { - throw new NotFoundException('Task not found'); + throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND); } const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery); @@ -235,11 +234,11 @@ export class TasksService { }); 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) => { @@ -419,14 +418,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; @@ -540,7 +539,7 @@ 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; @@ -732,7 +731,7 @@ export class TasksService { 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 +739,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..29e5e12 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 }; } @@ -1324,7 +1324,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 +1345,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/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.service.ts b/backend/src/modules/treatments/treatments.service.ts index 4572ea4..47b30c8 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'; @@ -136,7 +133,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; } @@ -561,13 +558,13 @@ 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); } 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 +572,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 +584,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 +636,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({ @@ -696,9 +697,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 +748,37 @@ 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 (!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 +921,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 +952,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({ @@ -1040,11 +1039,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 +1056,12 @@ 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 (!existsSync(attachment.storagePath)) { - throw new NotFoundException('File is no longer available'); + throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND); } return { @@ -1316,7 +1315,7 @@ export class TreatmentsService { select: { id: true }, }); if (!patient) { - throw new NotFoundException('Patient not found'); + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } @@ -1336,11 +1335,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 +1348,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 +1356,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 26ab69e..3b34cec 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1080,6 +1080,101 @@ "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.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c4cb5fa..1c8f582 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1081,6 +1081,101 @@ "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": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d58539e..4dbc56b 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -1080,6 +1080,101 @@ "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.", diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 7ebd9e8..38e26f7 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -20,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; interface LabCasesDispatchPanelProps { @@ -124,6 +125,7 @@ export function LabCasesDispatchPanel({ onCommentError, }: LabCasesDispatchPanelProps) { const t = useTranslations('treatment'); + const tErrors = useTranslations('errors'); const [prosthesisOptions, setProsthesisOptions] = useState([]); const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [pendingComment, setPendingComment] = useState(''); @@ -273,7 +275,7 @@ export function LabCasesDispatchPanel({ taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress, }); } catch (error) { - onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError')); + onCommentError?.(getUserFacingError(error, tErrors, t('dueDateUpdateError'))); } } diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 93c82ef..b60afcb 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -1,4 +1,5 @@ import { apiClient } from './client'; +import { getClientTimeZone } from '@/lib/i18n/clientTimeZone'; export type WorkingHoursPayload = { autoRepeatWeekly: boolean; @@ -42,7 +43,10 @@ export const accountApi = { upsertMyWorkingHours: async ( payload: WorkingHoursPayload, ): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.put('/auth/profile/working-hours', payload); + const response = await apiClient.put('/auth/profile/working-hours', { + ...payload, + timeZone: getClientTimeZone(), + }); return response.data; }, }; diff --git a/frontend/src/lib/api/appointments.ts b/frontend/src/lib/api/appointments.ts index d727b2e..d312c52 100644 --- a/frontend/src/lib/api/appointments.ts +++ b/frontend/src/lib/api/appointments.ts @@ -1,5 +1,6 @@ import { apiClient } from './client'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import { getClientTimeZone } from '@/lib/i18n/clientTimeZone'; import { toDateInputValue } from '@/components/appointments/appointmentTime'; export interface CreateAppointmentBody { @@ -27,7 +28,10 @@ export const appointmentsApi = { }, create: async (body: CreateAppointmentBody): Promise<{ success: boolean; data: AppointmentRecord }> => { - const response = await apiClient.post('/appointments', body); + const response = await apiClient.post('/appointments', { + ...body, + timeZone: getClientTimeZone(), + }); return response.data; }, @@ -35,7 +39,10 @@ export const appointmentsApi = { id: string, body: UpdateAppointmentBody, ): Promise<{ success: boolean; data: AppointmentRecord }> => { - const response = await apiClient.patch(`/appointments/${id}`, body); + const response = await apiClient.patch(`/appointments/${id}`, { + ...body, + timeZone: getClientTimeZone(), + }); return response.data; }, diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index 68772b2..760169c 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -1,4 +1,5 @@ import { apiClient } from './client'; +import { getClientTimeZone } from '@/lib/i18n/clientTimeZone'; export interface StaffMemberDto { id: string; @@ -142,7 +143,10 @@ export const staffApi = { blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; }, ): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, body); + const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, { + ...body, + timeZone: getClientTimeZone(), + }); return response.data; }, }; diff --git a/frontend/src/lib/i18n/clientTimeZone.ts b/frontend/src/lib/i18n/clientTimeZone.ts new file mode 100644 index 0000000..bc7a475 --- /dev/null +++ b/frontend/src/lib/i18n/clientTimeZone.ts @@ -0,0 +1,8 @@ +/** Browser IANA zone (e.g. Asia/Tehran). Used so the UTC server can interpret wall-clock hours. */ +export function getClientTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +} -- 2.53.0.windows.1 From e5c39c6b7ef1ed757bafe35c4b110c308607f272 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 02:41:05 +0330 Subject: [PATCH 4/8] bugfix: site renamed. Name component created. --- frontend/messages/en.json | 8 ++-- frontend/messages/fa.json | 8 ++-- frontend/messages/nl.json | 8 ++-- .../accept-organization-invite/page.tsx | 5 +-- .../(public)/forgot-password/page.tsx | 6 +-- .../src/app/[locale]/(public)/login/page.tsx | 5 +-- frontend/src/app/[locale]/(public)/page.tsx | 6 +-- .../app/[locale]/(public)/register/page.tsx | 5 +-- frontend/src/app/[locale]/layout.tsx | 2 +- .../ui/lab/CaseSheetPrintLayout.tsx | 5 ++- .../components/ui/shared/BrandWordmark.tsx | 44 +++++++++++++++++++ frontend/src/components/ui/shared/Sidebar.tsx | 5 ++- 12 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/ui/shared/BrandWordmark.tsx diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3b34cec..f1b8e57 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...", @@ -168,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": { @@ -854,7 +854,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", @@ -948,7 +948,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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 1c8f582..44c32f3 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1,6 +1,6 @@ { "common": { - "appName": "DyoLink", + "appName": "Nudentic", "loading": "در حال بارگذاری...", "loadingApp": "در حال بارگذاری برنامه...", "loadingWorkspace": "در حال بارگذاری فضای کاری...", @@ -168,7 +168,7 @@ "featureRealtimeDescription": "اعلان‌های فوری درباره تغییر وضعیت پرونده‌ها دریافت کنید.", "featureSecurityTitle": "امن و منطبق با استانداردها", "featureSecurityDescription": "منطبق با HIPAA و دارای امنیت در سطح سازمانی.", - "footerCopyright": "© ۲۰۲۶ DyoLink. تمامی حقوق محفوظ است.", + "footerCopyright": "© ۲۰۲۶ Nudentic. تمامی حقوق محفوظ است.", "termsAndConditions": "شرایط و ضوابط" }, "accountMenu": { @@ -855,7 +855,7 @@ }, "organizations": { "loadingOrganization": "در حال بارگذاری سازمان...", - "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در DyoLink نیستند، لینک دعوت ارسال کنید.", + "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در Nudentic نیستند، لینک دعوت ارسال کنید.", "invitationHistory": "تاریخچه دعوت‌نامه‌ها", "searchPlaceholder": "جستجوی {counterpart} بر اساس نام، ایمیل یا تلفن...", "backToList": "بازگشت به لیست", @@ -949,7 +949,7 @@ "passwordChanged": "رمز عبور به‌روزرسانی شد. لطفاً دوباره وارد شوید.", "passwordChangeFailed": "به‌روزرسانی رمز عبور انجام نشد. دوباره تلاش کنید.", "subscriptionsTitle": "اشتراک‌ها", - "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری DyoLink شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", + "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری Nudentic شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", "noSubscriptionNotice": "این سازمان اشتراک فعالی ندارد. برای شروع فرآیند خرید، یک طرح زیر را انتخاب کنید.", "currentPlan": "طرح فعلی", "planPrice": "قیمت طرح", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 4dbc56b..46c1ecf 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...", @@ -168,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": { @@ -854,7 +854,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", @@ -948,7 +948,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", 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 7745e07..90dcb3c 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -10,6 +10,7 @@ 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 { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; @@ -179,9 +180,7 @@ function AcceptOrganizationInviteContent() { header={ <> - - {tCommon('appName')} - +

{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..2cefe28 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 { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; 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={ <> - - {tCommon('appName')} - +

{t('forgotPasswordTitle')} diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index 35a6cc1..9ecd841 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -12,6 +12,7 @@ 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 { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Input } from '@/components/ui/shared/Input'; @@ -91,9 +92,7 @@ function LoginPageContent() { header={ <> - - {tCommon('appName')} - +

{t('signInTitle')} diff --git a/frontend/src/app/[locale]/(public)/page.tsx b/frontend/src/app/[locale]/(public)/page.tsx index 5f620c9..dc998fb 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 { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; 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 (
- - {tCommon('appName')} + +
diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 6d17c13..2bb9c52 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -11,6 +11,7 @@ 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 { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { Button } from '@/components/ui/shared/Button'; @@ -136,9 +137,7 @@ export default function RegisterPage() { header={ <> - - {tCommon('appName')} - +

{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/components/ui/lab/CaseSheetPrintLayout.tsx b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx index 1338d24..70ea09a 100644 --- a/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx +++ b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx @@ -12,6 +12,7 @@ import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay'; import type { LabCaseDetail } from '@/types/cases'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId } from '@/types/treatment'; +import { BrandWordmark, BRAND_PRIMARY_HEX } from '@/components/ui/shared/BrandWordmark'; export type CaseSheetLabels = { title: string; @@ -89,7 +90,9 @@ export function CaseSheetPrintLayout({ }} >
-

Dyolink

+

+ +

{labels.title}

{labCase.shareUrl ? ( diff --git a/frontend/src/components/ui/shared/BrandWordmark.tsx b/frontend/src/components/ui/shared/BrandWordmark.tsx new file mode 100644 index 0000000..080630a --- /dev/null +++ b/frontend/src/components/ui/shared/BrandWordmark.tsx @@ -0,0 +1,44 @@ +import type { CSSProperties } from 'react'; + +export const BRAND_NAME = 'Nudentic'; + +/** Light-theme primary — use in print/PDF where theme tokens are unsafe. */ +export const BRAND_PRIMARY_HEX = '#009cae'; + +type BrandWordmarkProps = { + className?: string; + style?: CSSProperties; + /** + * i-dot color. Omit to use the theme primary token. + * Pass a hex (e.g. `BRAND_PRIMARY_HEX`) for print/PDF capture. + */ + dotColor?: string; +}; + +function BrandI({ fill }: { fill: string }) { + return ( + + + + + ); +} + +export function BrandWordmark({ className, style, dotColor }: BrandWordmarkProps) { + const tittleFill = dotColor ?? 'var(--color-primary)'; + + return ( + + {BRAND_NAME} + + Nudent + + c + + + ); +} diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 027d3a7..c1e79a2 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -21,6 +21,7 @@ import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCou import { useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts'; import { badgeCountForPath } from '@/lib/tabBadgeUtils'; import { NavBadgePill } from '@/components/ui/shared/NavBadgePill'; +import { BrandWordmark } from '@/components/ui/shared/BrandWordmark'; import { canViewAppointmentsTab, canViewCases, @@ -110,7 +111,9 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) { }`} >
-

{tCommon('appName')}

+

+ +

{onClose ? ( +
+ +
+ + + + + + +
+ +
+

{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} + +