From 538121d653523d1303ea133f822eedd2d48746aa Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 18 Jul 2026 22:00:44 +0330 Subject: [PATCH] improvement: pdf generation added to cases feature. --- .cursor/rules/lab-cases.mdc | 6 +- .cursor/skills/lab-cases/SKILL.md | 31 +++ AGENTS.md | 4 +- .../migration.sql | 2 + backend/prisma/schema.prisma | 2 + backend/src/modules/cases/cases.controller.ts | 13 +- backend/src/modules/cases/cases.service.ts | 75 +++++- backend/src/modules/cases/dto/cases.dto.ts | 9 +- frontend/messages/en.json | 20 +- frontend/messages/fa.json | 20 +- frontend/messages/nl.json | 20 +- frontend/package-lock.json | 236 +++++++++++++++++ frontend/package.json | 1 + .../src/components/lab/caseDetailUtils.ts | 38 ++- frontend/src/components/lab/caseSheetPdf.ts | 139 ++++++++++ .../src/components/ui/lab/CaseDetailPanel.tsx | 126 +++++++-- .../components/ui/lab/CaseSheetFdiChart.tsx | 168 ++++++++++++ .../ui/lab/CaseSheetPrintLayout.tsx | 248 ++++++++++++++++++ .../components/ui/lab/CaseToothChartPanel.tsx | 18 +- frontend/src/components/ui/lab/CasesPage.tsx | 22 ++ .../src/components/ui/shared/Checkbox.tsx | 58 ++-- frontend/src/lib/api/cases.ts | 8 + frontend/src/types/cases.ts | 5 + 23 files changed, 1206 insertions(+), 63 deletions(-) create mode 100644 backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql create mode 100644 frontend/src/components/lab/caseSheetPdf.ts create mode 100644 frontend/src/components/ui/lab/CaseSheetFdiChart.tsx create mode 100644 frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc index 61db4cf..bee2fe2 100644 --- a/.cursor/rules/lab-cases.mdc +++ b/.cursor/rules/lab-cases.mdc @@ -1,6 +1,6 @@ --- -description: Lab Cases tab — prosthesis filter, auto-select, list cards -globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** +description: Lab Cases tab — prosthesis filter, auto-select, list cards, Case Sheet PDF +globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** alwaysApply: false --- @@ -11,5 +11,7 @@ alwaysApply: false - **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. - **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab. - **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`. +- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set. +- **Detail chrome:** Important Case checkbox (`labelPosition="start"`) then external-code input (no field title; placeholder only). Skill: `.cursor/skills/lab-cases/SKILL.md` diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 42586e3..202740a 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -38,8 +38,39 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task - Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) - Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. - Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) +- FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`). +- **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`. +- **Case Sheet PDF** — see below. - **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`. +## Case Sheet PDF + +**Entry:** Download PDF beside Comments on `CaseDetailPanel`. +**Builder:** [`caseSheetPdf.ts`](frontend/src/components/lab/caseSheetPdf.ts) → off-screen [`CaseSheetPrintLayout.tsx`](frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx) → `html2canvas` → `jspdf` A4 (ISO √2 — prints cleanly onto A3–A6). + +### html2canvas + Tailwind v4 (required) + +Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not** mount live `FdiToothChart` / Tailwind color classes into the capture tree. + +| Do | Don't | +|----|--------| +| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture | +| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF | +| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture | +| Prosthesis labels from **catalog** (then task label) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) | + +### Layout conventions + +- Sections: meta → parties → **Prosthesis** rows → **Tooth Chart** heading *above* the horizontal rule → chart → Comments. +- Connected badge: bold + underline (no border box — html2canvas centering is unreliable). +- Order number: `externalCode.trim()` when set, else short case id (`id` without hyphens, first 8, uppercased). +- Share QR when `shareUrl` present. + +### API + +- `LabCase.externalCode` (optional string, max 64) +- `PATCH /cases/:id/external-code` `{ externalCode: string | null }` — same edit permission as important flag + ## Live soft refresh Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent `loadCases` + selected `loadDetail` (keeps filters/selection; no full remount). Same event refreshes sidebar Cases badge via `useTabBadgeCounts`. See `.cursor/skills/notifications-inbox/SKILL.md`. diff --git a/AGENTS.md b/AGENTS.md index 120bc87..6c672fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ frontend/src/ **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`. -**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. **Lab case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. @@ -95,7 +95,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | | `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | -| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | +| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, Case Sheet PDF, external code | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | | `.cursor/skills/tab-badges/` | Sidebar tab badges: Cases/Tasks/Treatment, LabCaseActivity, tab-counts API, read cursors | | `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime | diff --git a/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql b/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql new file mode 100644 index 0000000..a10ed34 --- /dev/null +++ b/backend/prisma/migrations/20260718210000_lab_case_external_code/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "lab_cases" ADD COLUMN "externalCode" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bad97b7..66d1992 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -221,6 +221,8 @@ model LabCase { sentAt DateTime? dueDate DateTime? isImportant Boolean @default(false) + /// Optional code from an external lab app (e.g. exocad) for print/matching. + externalCode String? accessToken String? @unique treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 451dbf5..3bcc7b8 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -82,6 +82,17 @@ export class CasesController { return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language); } + @Patch(':id/external-code') + @ApiOperation({ summary: 'Set or clear the optional external order code for a case' }) + setCaseExternalCode( + @Param('id') id: string, + @Body() dto: UpdateLabCaseExternalCodeDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.setCaseExternalCode(id, dto, organizationId, req.user.id, req.user.language); + } + @Patch(':caseId/tasks/:taskId/assign') @ApiOperation({ summary: 'Assign or unassign a task to lab staff' }) assignTask( diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index fafaf31..94976fc 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -14,7 +14,7 @@ import { } from '../catalog/catalog-label.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; @@ -131,7 +131,15 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, + tasks: { + select: { + id: true, + status: true, + prosthesisTypeCode: true, + teeth: true, + selectionGroupId: true, + }, + }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -244,7 +252,15 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, + tasks: { + select: { + id: true, + status: true, + prosthesisTypeCode: true, + teeth: true, + selectionGroupId: true, + }, + }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -405,6 +421,46 @@ export class CasesService { return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } + async setCaseExternalCode( + labCaseId: string, + dto: UpdateLabCaseExternalCodeDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException('Case not found'); + } + + const externalCode = + dto.externalCode == null || !String(dto.externalCode).trim() + ? null + : String(dto.externalCode).trim().slice(0, 64); + + await this.prisma.labCase.update({ + where: { id: labCaseId }, + data: { externalCode }, + }); + + const labCase = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + async listAssignableStaff(labOrganizationId: string, actorUserId: string) { await this.assertCanEditCases(actorUserId, labOrganizationId); @@ -648,6 +704,7 @@ export class CasesService { dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, + externalCode: lc.externalCode ?? null, shareUrl, clinic: lc.treatment.organization, patient: lc.treatment.patient, @@ -665,6 +722,7 @@ export class CasesService { treatmentDetailId: row.treatmentDetailId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, + selectionGroupId: row.selectionGroupId?.trim() || undefined, })), attachments: lc.attachments.map((row) => ({ id: row.attachment.id, @@ -764,7 +822,16 @@ export class CasesService { groups.set(key, entry); } - return [...groups.values()]; + return [...groups.values()].map((group) => ({ + treatmentDetailId: group.treatmentDetailId, + teeth: [...new Set(group.teeth)], + treatmentType: group.treatmentType, + prosthesisTypeCode: group.prosthesisTypeCode, + prosthesisTypeLabel: group.prosthesisTypeLabel, + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, + tasks: group.tasks, + })); } private mapTask( diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index 74ecc98..a553caf 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,11 +1,18 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class UpdateLabCaseImportantDto { @IsBoolean() isImportant: boolean; } +export class UpdateLabCaseExternalCodeDto { + @IsOptional() + @IsString() + @MaxLength(64) + externalCode?: string | null; +} + export class AssignLabCaseTaskDto { @IsOptional() @IsUUID() diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ee0bb53..5e544af 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -433,7 +433,7 @@ "statusCompleted": "Completed", "importantLabel": "Important", "unreadCase": "Unread updates", - "markCaseImportant": "Mark case as important", + "markCaseImportant": "Important Case", "markImportant": "Mark as important", "lastUpdatedBy": "Updated by {name}", "lastUpdatedUnknown": "Not started yet", @@ -472,7 +472,23 @@ "shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.", "copyShareLink": "Copy link", "shareLinkCopied": "Link copied to clipboard", - "shareQrUnavailable": "QR preview unavailable" + "shareQrUnavailable": "QR preview unavailable", + "downloadCaseSheet": "Download PDF", + "caseSheetTitle": "Case Sheet", + "caseSheetOrderNumber": "Order number", + "caseSheetOrderDate": "Order date", + "caseSheetDateDue": "Date due", + "caseSheetSender": "Sender", + "caseSheetRecipient": "Recipient", + "caseSheetPatient": "Patient", + "caseSheetPatientId": "Mobile", + "caseSheetProsthesis": "Prosthesis", + "caseSheetToothChart": "Tooth Chart", + "caseSheetComments": "Comments", + "caseSheetNoComments": "No comments.", + "errorDownloadCaseSheet": "Could not download the case sheet PDF.", + "externalCodePlaceholder": "external code e.g. exocad code", + "errorUpdateExternalCode": "Could not save the external code." }, "labCaseAccess": { "pageTitle": "Case tasks", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 3dd5791..cdf27d3 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -433,7 +433,7 @@ "statusCompleted": "انجام شده", "importantLabel": "مهم", "unreadCase": "به‌روزرسانی‌های خوانده‌نشده", - "markCaseImportant": "علامت‌گذاری پرونده به‌عنوان مهم", + "markCaseImportant": "پرونده مهم", "markImportant": "علامت‌گذاری به عنوان مهم", "lastUpdatedBy": "به‌روزرسانی توسط {name}", "lastUpdatedUnknown": "هنوز شروع نشده", @@ -473,7 +473,23 @@ "shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.", "copyShareLink": "کپی لینک", "shareLinkCopied": "لینک در کلیپ‌بورد کپی شد", - "shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست" + "shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست", + "downloadCaseSheet": "دانلود PDF", + "caseSheetTitle": "برگه پرونده", + "caseSheetOrderNumber": "شماره سفارش", + "caseSheetOrderDate": "تاریخ سفارش", + "caseSheetDateDue": "موعد تحویل", + "caseSheetSender": "فرستنده", + "caseSheetRecipient": "گیرنده", + "caseSheetPatient": "بیمار", + "caseSheetPatientId": "موبایل", + "caseSheetProsthesis": "پروتز", + "caseSheetToothChart": "نمودار دندان", + "caseSheetComments": "توضیحات", + "caseSheetNoComments": "بدون توضیحات.", + "errorDownloadCaseSheet": "دانلود PDF برگه پرونده ممکن نشد.", + "externalCodePlaceholder": "کد خارجی مثلاً کد exocad", + "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد." }, "labCaseAccess": { "pageTitle": "وظایف پرونده", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 7573cdb..b6ced7d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -433,7 +433,7 @@ "statusCompleted": "Voltooid", "importantLabel": "Belangrijk", "unreadCase": "Ongelezen updates", - "markCaseImportant": "Zaak als belangrijk markeren", + "markCaseImportant": "Belangrijke zaak", "markImportant": "Markeren als belangrijk", "lastUpdatedBy": "Bijgewerkt door {name}", "lastUpdatedUnknown": "Nog niet gestart", @@ -473,7 +473,23 @@ "shareQrDialogSubtitle": "Scan de QR-code of kopieer de link om de taken van dit dossier te openen.", "copyShareLink": "Link kopiëren", "shareLinkCopied": "Link gekopieerd naar klembord", - "shareQrUnavailable": "QR-voorbeeld niet beschikbaar" + "shareQrUnavailable": "QR-voorbeeld niet beschikbaar", + "downloadCaseSheet": "PDF downloaden", + "caseSheetTitle": "Case Sheet", + "caseSheetOrderNumber": "Bestelnummer", + "caseSheetOrderDate": "Besteldatum", + "caseSheetDateDue": "Opleverdatum", + "caseSheetSender": "Afzender", + "caseSheetRecipient": "Ontvanger", + "caseSheetPatient": "Patiënt", + "caseSheetPatientId": "Mobiel", + "caseSheetProsthesis": "Prothese", + "caseSheetToothChart": "Tooth Chart", + "caseSheetComments": "Opmerkingen", + "caseSheetNoComments": "Geen opmerkingen.", + "errorDownloadCaseSheet": "Case Sheet PDF kon niet worden gedownload.", + "externalCodePlaceholder": "externe code bijv. exocad-code", + "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen." }, "labCaseAccess": { "pageTitle": "Dossiertaken", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5159052..31dfa4d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "js-cookie": "^3.0.5", + "jspdf": "^4.2.1", "lucide-react": "^0.577.0", "next": "16.1.6", "next-intl": "^4.13.0", @@ -242,6 +243,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", @@ -2271,6 +2281,19 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", @@ -2292,6 +2315,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3185,6 +3215,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.7", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", @@ -3335,6 +3375,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -3413,6 +3473,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3428,6 +3500,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -3714,6 +3796,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4474,6 +4566,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", @@ -4484,6 +4587,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4898,6 +5007,20 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/icu-minify": { "version": "4.13.0", "resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz", @@ -4994,6 +5117,12 @@ "@formatjs/icu-messageformat-parser": "3.5.11" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5524,6 +5653,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6422,6 +6568,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -6462,6 +6624,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -6590,6 +6759,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", @@ -6743,6 +6922,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6822,6 +7008,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7167,6 +7363,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7366,6 +7572,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -7387,6 +7603,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7762,6 +7988,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 44256a3..ba8e631 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "js-cookie": "^3.0.5", + "jspdf": "^4.2.1", "lucide-react": "^0.577.0", "next": "16.1.6", "next-intl": "^4.13.0", diff --git a/frontend/src/components/lab/caseDetailUtils.ts b/frontend/src/components/lab/caseDetailUtils.ts index 91cd188..77cd797 100644 --- a/frontend/src/components/lab/caseDetailUtils.ts +++ b/frontend/src/components/lab/caseDetailUtils.ts @@ -1,6 +1,7 @@ import type { LabCaseDetail } from '@/types/cases'; import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel'; import { formatAppDateTime } from '@/lib/i18n/format'; +import type { FdiToothId } from '@/types/treatment'; export function formatPatientName(patient: { firstName: string; lastName: string }) { return `${patient.firstName} ${patient.lastName}`.trim(); @@ -13,23 +14,46 @@ export function formatCaseDateTime(value: string | null, locale: string) { export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] { if (labCase.toothProsthesis.length > 0) { - const byCode = new Map(); + const byKey = new Map(); for (const row of labCase.toothProsthesis) { - const teeth = byCode.get(row.prosthesisTypeCode) ?? []; - if (!teeth.includes(row.tooth)) teeth.push(row.tooth); - byCode.set(row.prosthesisTypeCode, teeth); + const selectionGroupId = row.selectionGroupId?.trim() || ''; + const key = `${selectionGroupId}::${row.prosthesisTypeCode}`; + const existing = byKey.get(key); + if (existing) { + if (!existing.teeth.includes(row.tooth)) existing.teeth.push(row.tooth); + } else { + byKey.set(key, { + prosthesisTypeCode: row.prosthesisTypeCode, + teeth: [row.tooth], + selectionGroupId: selectionGroupId || undefined, + connected: Boolean(selectionGroupId), + }); + } } - return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({ - prosthesisTypeCode, - teeth, + return [...byKey.values()].map((row) => ({ + ...row, + connected: Boolean(row.selectionGroupId) && row.teeth.length > 1, })); } return labCase.tasksByTooth.map((g) => ({ prosthesisTypeCode: g.prosthesisTypeCode, teeth: g.teeth, + selectionGroupId: g.selectionGroupId, + connected: Boolean(g.connected ?? (g.selectionGroupId && g.teeth.length > 1)), })); } +/** Teeth that belong to a multi-tooth connected bridge (for FDI chart dots). */ +export function buildCaseConnectedTeeth(labCase: LabCaseDetail): Set { + const set = new Set(); + const rows = buildCaseProsthesisRows(labCase); + for (const row of rows) { + if (!row.connected || row.teeth.length < 2) continue; + for (const tooth of row.teeth) set.add(tooth as FdiToothId); + } + return set; +} + export function latestCaseAttachment(labCase: LabCaseDetail) { if (!labCase.attachments.length) return null; return [...labCase.attachments].sort( diff --git a/frontend/src/components/lab/caseSheetPdf.ts b/frontend/src/components/lab/caseSheetPdf.ts new file mode 100644 index 0000000..4ffdf0b --- /dev/null +++ b/frontend/src/components/lab/caseSheetPdf.ts @@ -0,0 +1,139 @@ +import { createRoot } from 'react-dom/client'; +import { createElement } from 'react'; +import { jsPDF } from 'jspdf'; +import html2canvas from 'html2canvas'; +import { + CaseSheetPrintLayout, + type CaseSheetLabels, +} from '@/components/ui/lab/CaseSheetPrintLayout'; +import type { LabCaseDetail } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +type DownloadCaseSheetPdfArgs = { + labCase: LabCaseDetail; + locale: string; + labels: CaseSheetLabels; + prosthesisCatalog: readonly ProsthesisCatalogEntry[]; + fileName?: string; +}; + +function waitForPaint(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +/** + * Renders an off-screen Case Sheet, captures it, and downloads an A4 PDF + * (ISO A-series √2 ratio — prints cleanly onto A3–A6). + * + * Print layout uses hex-only inline styles so html2canvas is not fed Tailwind + * `lab()` / `oklch()` from global stylesheets. + */ +export async function downloadCaseSheetPdf({ + labCase, + locale, + labels, + prosthesisCatalog, + fileName, +}: DownloadCaseSheetPdfArgs): Promise { + const host = document.createElement('div'); + host.setAttribute('aria-hidden', 'true'); + host.style.cssText = + 'position:fixed;left:-12000px;top:0;z-index:-1;pointer-events:none;opacity:1;'; + document.body.appendChild(host); + + const root = createRoot(host); + try { + root.render( + createElement(CaseSheetPrintLayout, { + labCase, + locale, + labels, + prosthesisCatalog, + }), + ); + await waitForPaint(); + await new Promise((r) => setTimeout(r, 80)); + + const sheet = host.querySelector('[data-case-sheet-root]'); + if (!(sheet instanceof HTMLElement)) { + throw new Error('Case sheet root not found'); + } + + const canvas = await html2canvas(sheet, { + scale: 2, + backgroundColor: '#ffffff', + useCORS: true, + logging: false, + // Drop app stylesheets so parsers never see lab()/oklch() from Tailwind. + onclone: (clonedDoc) => { + clonedDoc + .querySelectorAll('style, link[rel="stylesheet"]') + .forEach((node) => node.remove()); + }, + }); + + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 8; + const usableWidth = pageWidth - margin * 2; + const usableHeight = pageHeight - margin * 2; + + const imgData = canvas.toDataURL('image/png'); + const imgHeight = (canvas.height * usableWidth) / canvas.width; + + if (imgHeight <= usableHeight) { + pdf.addImage(imgData, 'PNG', margin, margin, usableWidth, imgHeight); + } else { + let remainingHeightPx = canvas.height; + let sourceY = 0; + const pageHeightPx = (usableHeight * canvas.width) / usableWidth; + let pageIndex = 0; + + while (remainingHeightPx > 0) { + if (pageIndex > 0) pdf.addPage(); + const sliceHeight = Math.min(pageHeightPx, remainingHeightPx); + const pageCanvas = document.createElement('canvas'); + pageCanvas.width = canvas.width; + pageCanvas.height = sliceHeight; + const ctx = pageCanvas.getContext('2d'); + if (!ctx) break; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height); + ctx.drawImage( + canvas, + 0, + sourceY, + canvas.width, + sliceHeight, + 0, + 0, + canvas.width, + sliceHeight, + ); + const sliceData = pageCanvas.toDataURL('image/png'); + const sliceMm = (sliceHeight * usableWidth) / canvas.width; + pdf.addImage(sliceData, 'PNG', margin, margin, usableWidth, sliceMm); + sourceY += sliceHeight; + remainingHeightPx -= sliceHeight; + pageIndex += 1; + } + } + + const safeName = + fileName ?? + `case-sheet-${labCase.id.replace(/-/g, '').slice(0, 8)}.pdf`; + pdf.save(safeName); + } finally { + root.unmount(); + host.remove(); + } +} diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 8662ac9..db27f4f 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -1,11 +1,12 @@ 'use client'; import { useEffect, useMemo, useState, type ReactNode } from 'react'; -import { useTranslations } from 'next-intl'; -import { MessageSquare } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Download, MessageSquare } from 'lucide-react'; import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { Input } from '@/components/ui/shared/Input'; import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; @@ -22,11 +23,15 @@ import { } from '@/components/treatment/prosthesisTypeDisplay'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { + buildCaseConnectedTeeth, buildCaseProsthesisRows, formatCaseDateTime, formatPatientName, latestCaseAttachment, } from '@/components/lab/caseDetailUtils'; +import { downloadCaseSheetPdf } from '@/components/lab/caseSheetPdf'; +import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; +import { useToast } from '@/lib/hooks/useToast'; import type { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; @@ -65,6 +70,8 @@ export interface CaseDetailPanelProps { canEditImportant?: boolean; updatingImportant?: boolean; onImportantChange?: (checked: boolean) => void; + updatingExternalCode?: boolean; + onExternalCodeChange?: (externalCode: string | null) => void; commentsSection?: ReactNode; assignableStaff?: AssignableTaskStaff[]; canAssignTasks?: boolean; @@ -85,6 +92,8 @@ export function CaseDetailPanel({ canEditImportant = false, updatingImportant = false, onImportantChange, + updatingExternalCode = false, + onExternalCodeChange, commentsSection, assignableStaff = [], canAssignTasks = false, @@ -92,9 +101,18 @@ export function CaseDetailPanel({ onAssignTask, }: CaseDetailPanelProps) { const t = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const intlLocale = useLocale(); + const toast = useToast(); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false); const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + const [downloadingPdf, setDownloadingPdf] = useState(false); + const [externalCodeDraft, setExternalCodeDraft] = useState(labCase.externalCode ?? ''); + + useEffect(() => { + setExternalCodeDraft(labCase.externalCode ?? ''); + }, [labCase.id, labCase.externalCode]); useEffect(() => { void prosthesisCatalogApi @@ -104,8 +122,40 @@ export function CaseDetailPanel({ }, []); const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]); + const connectedTeeth = useMemo(() => buildCaseConnectedTeeth(labCase), [labCase]); const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]); + async function handleDownloadCaseSheet() { + setDownloadingPdf(true); + try { + await downloadCaseSheetPdf({ + labCase, + locale: intlLocale, + prosthesisCatalog, + labels: { + title: t('caseSheetTitle'), + orderNumber: t('caseSheetOrderNumber'), + orderDate: t('caseSheetOrderDate'), + dateDue: t('caseSheetDateDue'), + sender: t('caseSheetSender'), + recipient: t('caseSheetRecipient'), + patient: t('caseSheetPatient'), + patientId: t('caseSheetPatientId'), + prosthesis: t('caseSheetProsthesis'), + toothChart: t('caseSheetToothChart'), + connected: tTreatment('connectedBadge'), + teeth: t('teethLabel'), + comments: t('caseSheetComments'), + noComments: t('caseSheetNoComments'), + }, + }); + } catch { + toast.showError(t('errorDownloadCaseSheet')); + } finally { + setDownloadingPdf(false); + } + } + return (
@@ -144,22 +194,55 @@ export function CaseDetailPanel({
{canEditImportant ? ( - onImportantChange?.(checked)} - /> +
+ onImportantChange?.(checked)} + /> +
+ setExternalCodeDraft(e.target.value)} + onBlur={() => { + const next = externalCodeDraft.trim() || null; + const prev = labCase.externalCode?.trim() || null; + if (next === prev) return; + onExternalCodeChange?.(next); + }} + /> +
+
+ ) : labCase.externalCode?.trim() ? ( +

{labCase.externalCode.trim()}

) : null} - {showCommentsButton && onCommentsClick ? ( - + ) : null} + - ) : null} +
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
{previewAttachment && labCase.attachments.length > 0 ? ( @@ -192,6 +275,7 @@ export function CaseDetailPanel({ @@ -201,8 +285,11 @@ export function CaseDetailPanel({

{t('treatmentDetails')}

{treatmentLabel(labCase.detail.treatmentType)}
-
- {t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'} +
+ + {t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'} + + {connectedTeeth.size > 0 ? : null}
{labCase.detail.comment ? (
{labCase.detail.comment}
@@ -218,7 +305,7 @@ export function CaseDetailPanel({ ) : ( labCase.tasksByTooth.map((group) => (
@@ -238,6 +325,7 @@ export function CaseDetailPanel({ prosthesis: group.prosthesisTypeLabel, })} + {group.connected ? : null}
    {group.tasks.map((task) => ( diff --git a/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx b/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx new file mode 100644 index 0000000..2fae32b --- /dev/null +++ b/frontend/src/components/ui/lab/CaseSheetFdiChart.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { + FDI_LOWER_LEFT_TO_RIGHT, + FDI_UPPER_LEFT_TO_RIGHT, +} from '@/components/treatment/fdiToothMeta'; +import { + prosthesisTypeColor, + prosthesisTypeColorFromCatalog, +} from '@/components/treatment/prosthesisTypeDisplay'; +import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; +import type { FdiToothId } from '@/types/treatment'; + +type CaseSheetFdiChartProps = { + selected: ReadonlySet; + connectedTeeth: ReadonlySet; + prosthesisRows: CaseToothChartProsthesisRow[]; + prosthesisCatalog?: readonly ProsthesisCatalogEntry[]; +}; + +const CELL = 42; +const ARCH_H = 72; +const GAP = 10; + +/** + * PDF-safe FDI preview: only hex/rgb colors (no Tailwind / CSS variables). + * html2canvas cannot parse modern `lab()` / `oklch()` from app stylesheets. + */ +export function CaseSheetFdiChart({ + selected, + connectedTeeth, + prosthesisRows, + prosthesisCatalog, +}: CaseSheetFdiChartProps) { + const colors: Partial> = {}; + prosthesisRows.forEach((row, index) => { + const color = prosthesisCatalog?.length + ? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog) + : prosthesisTypeColor(row.prosthesisTypeCode, index); + for (const tooth of row.teeth) { + colors[tooth as FdiToothId] = color; + } + }); + + const width = FDI_UPPER_LEFT_TO_RIGHT.length * CELL; + const height = ARCH_H * 2 + GAP; + const midX = width / 2; + const midY = ARCH_H + GAP / 2; + + return ( + + {/* Vertical midline (left/right quadrants) */} + + {/* Horizontal separator (upper/lower arches) */} + + + + + + ); +} + +function ArchRow({ + teeth, + selected, + connectedTeeth, + colors, + connectedMarksBelow, + yOffset, +}: { + teeth: readonly FdiToothId[]; + selected: ReadonlySet; + connectedTeeth: ReadonlySet; + colors: Partial>; + connectedMarksBelow: boolean; + yOffset: number; +}) { + return ( + + {teeth.map((fdi, index) => { + const x = index * CELL + CELL / 2; + const isSelected = selected.has(fdi); + const isConnected = connectedTeeth.has(fdi); + const next = teeth[index + 1]; + const linkToNext = Boolean( + isConnected && next && connectedTeeth.has(next) && selected.has(next), + ); + const fill = isSelected ? (colors[fdi] ?? '#94a3b8') : '#f8fafc'; + const stroke = isSelected ? '#334155' : '#cbd5e1'; + const cy = connectedMarksBelow ? 28 : 44; + const markY = connectedMarksBelow ? 58 : 14; + + return ( + + {linkToNext ? ( + + ) : null} + + + {fdi} + + {isConnected && isSelected ? ( + + ) : null} + + ); + })} + + ); +} diff --git a/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx new file mode 100644 index 0000000..1338d24 --- /dev/null +++ b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx @@ -0,0 +1,248 @@ +'use client'; + +import QRCode from 'react-qr-code'; +import { CaseSheetFdiChart } from '@/components/ui/lab/CaseSheetFdiChart'; +import { + buildCaseConnectedTeeth, + buildCaseProsthesisRows, + formatCaseDateTime, + formatPatientName, +} from '@/components/lab/caseDetailUtils'; +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'; + +export type CaseSheetLabels = { + title: string; + orderNumber: string; + orderDate: string; + dateDue: string; + sender: string; + recipient: string; + patient: string; + patientId: string; + prosthesis: string; + toothChart: string; + connected: string; + teeth: string; + comments: string; + noComments: string; +}; + +type CaseSheetPrintLayoutProps = { + labCase: LabCaseDetail; + locale: string; + labels: CaseSheetLabels; + prosthesisCatalog: readonly ProsthesisCatalogEntry[]; +}; + +const muted = { color: '#64748b', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase' as const }; +const body = { color: '#0f172a', fontSize: 12 }; +const rule = { borderTop: '1px solid #cbd5e1', marginTop: 18, paddingTop: 12 }; + +/** + * Off-screen A4 layout for PDF capture. + * Uses only hex/rgb inline styles — html2canvas cannot parse Tailwind v4 `lab()` / `oklch()`. + */ +export function CaseSheetPrintLayout({ + labCase, + locale, + labels, + prosthesisCatalog, +}: CaseSheetPrintLayoutProps) { + const prosthesisRows = buildCaseProsthesisRows(labCase); + const connectedTeeth = buildCaseConnectedTeeth(labCase); + const selected = new Set(); + if (labCase.detail) { + for (const tooth of labCase.detail.teeth) selected.add(tooth as FdiToothId); + } + const labName = + labCase.sends[0]?.organizationName ?? + (labCase.sends.map((s) => s.organizationName).filter(Boolean).join(', ') || '—'); + const fallbackOrder = labCase.id.replace(/-/g, '').slice(0, 8).toUpperCase(); + const orderNumber = labCase.externalCode?.trim() || fallbackOrder; + + return ( +
    +
    +
    +

    Dyolink

    +

    {labels.title}

    +
    + {labCase.shareUrl ? ( +
    + +
    + ) : null} +
    + +
    + + + +
    + +
    + + + +
    + +
    +

    {labels.prosthesis}

    +
      + {prosthesisRows.length === 0 ? ( +
    • + ) : ( + prosthesisRows.map((row) => { + const fromCatalog = prosthesisCatalog.find( + (e) => e.code === row.prosthesisTypeCode, + )?.label; + const fromTasks = labCase.tasksByTooth.find( + (g) => g.prosthesisTypeCode === row.prosthesisTypeCode, + )?.prosthesisTypeLabel; + const label = + fromCatalog?.trim() || + fromTasks?.trim() || + row.prosthesisTypeCode.replace(/_/g, ' '); + return ( +
    • +
      + {label} + {row.connected ? ( + + {labels.connected} + + ) : null} +
      +

      + {labels.teeth}: {formatToothList(row.teeth)} +

      +
    • + ); + }) + )} +
    +
    + + {selected.size > 0 ? ( + <> +

    + {labels.toothChart} +

    +
    + +
    + + ) : null} + +
    +

    {labels.comments}

    +

    + {labCase.detail?.comment?.trim() || labels.noComments} +

    +
    +
    + ); +} + +function MetaBlock({ label, value }: { label: string; value: string }) { + return ( +
    +

    {label}

    +

    {value}

    +
    + ); +} + +function PartyBlock({ + label, + title, + subtitle, +}: { + label: string; + title: string; + subtitle?: string; +}) { + return ( +
    +

    {label}

    +

    {title}

    + {subtitle ? ( +

    {subtitle}

    + ) : null} +
    + ); +} diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index aabbd3a..7acbc44 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -13,22 +13,27 @@ export interface CaseToothChartDetail { export interface CaseToothChartProsthesisRow { teeth: string[]; prosthesisTypeCode: string; + selectionGroupId?: string; + connected?: boolean; } interface CaseToothChartPanelProps { details: CaseToothChartDetail[]; /** Prosthesis mapping from case tasks or toothProsthesis rows. */ prosthesisRows: CaseToothChartProsthesisRow[]; + /** Teeth in multi-tooth connected bridges (link dots on the chart). */ + connectedTeeth?: ReadonlySet; prosthesisCatalog?: readonly ProsthesisCatalogEntry[]; scale?: number; compact?: boolean; className?: string; } -/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */ +/** Read-only FDI chart for lab case detail — prosthesis-type glow + connected bridge dots. */ export function CaseToothChartPanel({ details, prosthesisRows, + connectedTeeth, prosthesisCatalog, scale = 1, compact = true, @@ -55,6 +60,16 @@ export function CaseToothChartPanel({ return colors; }, [prosthesisRows, prosthesisCatalog]); + const resolvedConnected = useMemo(() => { + if (connectedTeeth) return connectedTeeth; + const set = new Set(); + for (const row of prosthesisRows) { + if (!row.connected || row.teeth.length < 2) continue; + for (const tooth of row.teeth) set.add(tooth as FdiToothId); + } + return set; + }, [connectedTeeth, prosthesisRows]); + if (selected.size === 0) return null; return ( @@ -63,6 +78,7 @@ export function CaseToothChartPanel({ readOnly scale={scale} toothColors={toothColors} + connectedTeeth={resolvedConnected.size > 0 ? resolvedConnected : undefined} compact={compact} className={className} /> diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index cdfd8d5..c399f1f 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -75,6 +75,7 @@ export function CasesPage() { const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingImportant, setUpdatingImportant] = useState(false); + const [updatingExternalCode, setUpdatingExternalCode] = useState(false); const [assignableStaff, setAssignableStaff] = useState([]); const [assigningTaskId, setAssigningTaskId] = useState(null); const [commentCount, setCommentCount] = useState(0); @@ -330,6 +331,25 @@ export function CasesPage() { } } + async function handleCaseExternalCodeChange(externalCode: string | null) { + if (!selectedCaseId || !canEdit || !selectedCase) return; + + const previousCase = selectedCase; + setSelectedCase({ ...selectedCase, externalCode }); + + setUpdatingExternalCode(true); + toast.setError(''); + try { + const response = await casesApi.setCaseExternalCode(selectedCaseId, externalCode); + setSelectedCase(response.data); + } catch (error: unknown) { + setSelectedCase(previousCase); + toast.showError(getUserFacingError(error, tErrors, t('errorUpdateExternalCode'))); + } finally { + setUpdatingExternalCode(false); + } + } + const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`; return ( @@ -550,6 +570,8 @@ export function CasesPage() { canEditImportant={canEdit} updatingImportant={updatingImportant} onImportantChange={(checked) => void handleCaseImportantToggle(checked)} + updatingExternalCode={updatingExternalCode} + onExternalCodeChange={(code) => void handleCaseExternalCodeChange(code)} assignableStaff={assignableStaff} canAssignTasks={canEdit} assigningTaskId={assigningTaskId} diff --git a/frontend/src/components/ui/shared/Checkbox.tsx b/frontend/src/components/ui/shared/Checkbox.tsx index 453a12e..9443a92 100644 --- a/frontend/src/components/ui/shared/Checkbox.tsx +++ b/frontend/src/components/ui/shared/Checkbox.tsx @@ -8,6 +8,8 @@ type CheckboxProps = { onChange: (checked: boolean) => void; disabled?: boolean; label: string; + /** Where the label sits relative to the box. Default: after the box. */ + labelPosition?: 'start' | 'end'; id?: string; className?: string; }; @@ -21,6 +23,7 @@ export function Checkbox({ onChange, disabled = false, label, + labelPosition = 'end', id, className = '', }: CheckboxProps) { @@ -39,6 +42,30 @@ export function Checkbox({ } }; + const box = ( + + + + ); + + const text = {label}; + return ( ); } diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts index a20a1cf..79249e5 100644 --- a/frontend/src/lib/api/cases.ts +++ b/frontend/src/lib/api/cases.ts @@ -49,6 +49,14 @@ export const casesApi = { return response.data; }, + setCaseExternalCode: async ( + caseId: string, + externalCode: string | null, + ): Promise<{ success: boolean; data: LabCaseDetail }> => { + const response = await apiClient.patch(`/cases/${caseId}/external-code`, { externalCode }); + return response.data; + }, + getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise => { const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, { responseType: 'blob', diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index f69c58e..54fcf76 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -63,6 +63,8 @@ export interface LabCaseTaskGroup { treatmentType: string; prosthesisTypeCode: string; prosthesisTypeLabel: string; + selectionGroupId?: string; + connected?: boolean; tasks: LabCaseTask[]; } @@ -92,6 +94,8 @@ export interface LabCaseDetail { dueDate: string | null; isOverdue: boolean; isImportant: boolean; + /** Optional external lab-app code (e.g. exocad); used as PDF order number when set. */ + externalCode?: string | null; clinic: { id: string; name: string }; patient: { id: string; @@ -111,6 +115,7 @@ export interface LabCaseDetail { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string; + selectionGroupId?: string; }>; attachments: LabCaseAttachmentMeta[]; sends: Array<{