improvement: pdf generation added to cases feature.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Lab Cases tab — prosthesis filter, auto-select, list cards
|
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/lab/caseDetailUtils.ts,backend/src/modules/cases/**
|
globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/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
|
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.
|
- **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.
|
- **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`.
|
- **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`
|
Skill: `.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`)
|
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
|
||||||
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls.
|
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls.
|
||||||
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
- 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`.
|
- **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
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -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 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):**
|
**Lab case share link (quick ref):**
|
||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- 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/add-feature/` | New tab, API module, or end-to-end feature |
|
||||||
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth |
|
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth |
|
||||||
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
|
| `.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/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/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 |
|
| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime |
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "lab_cases" ADD COLUMN "externalCode" TEXT;
|
||||||
@@ -221,6 +221,8 @@ model LabCase {
|
|||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
isImportant Boolean @default(false)
|
isImportant Boolean @default(false)
|
||||||
|
/// Optional code from an external lab app (e.g. exocad) for print/matching.
|
||||||
|
externalCode String?
|
||||||
accessToken String? @unique
|
accessToken String? @unique
|
||||||
|
|
||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CasesService } from './cases.service';
|
import { CasesService } from './cases.service';
|
||||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||||
|
|
||||||
@ApiTags('cases')
|
@ApiTags('cases')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -82,6 +82,17 @@ export class CasesController {
|
|||||||
return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language);
|
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')
|
@Patch(':caseId/tasks/:taskId/assign')
|
||||||
@ApiOperation({ summary: 'Assign or unassign a task to lab staff' })
|
@ApiOperation({ summary: 'Assign or unassign a task to lab staff' })
|
||||||
assignTask(
|
assignTask(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||||
import {
|
import {
|
||||||
isLabCaseOverdue,
|
isLabCaseOverdue,
|
||||||
} from '../../common/lab-case-due-date';
|
} from '../../common/lab-case-due-date';
|
||||||
@@ -131,7 +131,15 @@ export class CasesService {
|
|||||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
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' }],
|
orderBy: [{ sentAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
@@ -244,7 +252,15 @@ export class CasesService {
|
|||||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
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' }],
|
orderBy: [{ sentAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
@@ -405,6 +421,46 @@ export class CasesService {
|
|||||||
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
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) {
|
async listAssignableStaff(labOrganizationId: string, actorUserId: string) {
|
||||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
@@ -648,6 +704,7 @@ export class CasesService {
|
|||||||
dueDate: lc.dueDate?.toISOString() ?? null,
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||||
isImportant: lc.isImportant,
|
isImportant: lc.isImportant,
|
||||||
|
externalCode: lc.externalCode ?? null,
|
||||||
shareUrl,
|
shareUrl,
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: lc.treatment.patient,
|
patient: lc.treatment.patient,
|
||||||
@@ -665,6 +722,7 @@ export class CasesService {
|
|||||||
treatmentDetailId: row.treatmentDetailId,
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
tooth: row.tooth,
|
tooth: row.tooth,
|
||||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
selectionGroupId: row.selectionGroupId?.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
attachments: lc.attachments.map((row) => ({
|
attachments: lc.attachments.map((row) => ({
|
||||||
id: row.attachment.id,
|
id: row.attachment.id,
|
||||||
@@ -764,7 +822,16 @@ export class CasesService {
|
|||||||
groups.set(key, entry);
|
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(
|
private mapTask(
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Transform } from 'class-transformer';
|
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 {
|
export class UpdateLabCaseImportantDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateLabCaseExternalCodeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
externalCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class AssignLabCaseTaskDto {
|
export class AssignLabCaseTaskDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -433,7 +433,7 @@
|
|||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"importantLabel": "Important",
|
"importantLabel": "Important",
|
||||||
"unreadCase": "Unread updates",
|
"unreadCase": "Unread updates",
|
||||||
"markCaseImportant": "Mark case as important",
|
"markCaseImportant": "Important Case",
|
||||||
"markImportant": "Mark as important",
|
"markImportant": "Mark as important",
|
||||||
"lastUpdatedBy": "Updated by {name}",
|
"lastUpdatedBy": "Updated by {name}",
|
||||||
"lastUpdatedUnknown": "Not started yet",
|
"lastUpdatedUnknown": "Not started yet",
|
||||||
@@ -472,7 +472,23 @@
|
|||||||
"shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.",
|
"shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.",
|
||||||
"copyShareLink": "Copy link",
|
"copyShareLink": "Copy link",
|
||||||
"shareLinkCopied": "Link copied to clipboard",
|
"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": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "Case tasks",
|
"pageTitle": "Case tasks",
|
||||||
|
|||||||
@@ -433,7 +433,7 @@
|
|||||||
"statusCompleted": "انجام شده",
|
"statusCompleted": "انجام شده",
|
||||||
"importantLabel": "مهم",
|
"importantLabel": "مهم",
|
||||||
"unreadCase": "بهروزرسانیهای خواندهنشده",
|
"unreadCase": "بهروزرسانیهای خواندهنشده",
|
||||||
"markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
|
"markCaseImportant": "پرونده مهم",
|
||||||
"markImportant": "علامتگذاری به عنوان مهم",
|
"markImportant": "علامتگذاری به عنوان مهم",
|
||||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||||
"lastUpdatedUnknown": "هنوز شروع نشده",
|
"lastUpdatedUnknown": "هنوز شروع نشده",
|
||||||
@@ -473,7 +473,23 @@
|
|||||||
"shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.",
|
"shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.",
|
||||||
"copyShareLink": "کپی لینک",
|
"copyShareLink": "کپی لینک",
|
||||||
"shareLinkCopied": "لینک در کلیپبورد کپی شد",
|
"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": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "وظایف پرونده",
|
"pageTitle": "وظایف پرونده",
|
||||||
|
|||||||
@@ -433,7 +433,7 @@
|
|||||||
"statusCompleted": "Voltooid",
|
"statusCompleted": "Voltooid",
|
||||||
"importantLabel": "Belangrijk",
|
"importantLabel": "Belangrijk",
|
||||||
"unreadCase": "Ongelezen updates",
|
"unreadCase": "Ongelezen updates",
|
||||||
"markCaseImportant": "Zaak als belangrijk markeren",
|
"markCaseImportant": "Belangrijke zaak",
|
||||||
"markImportant": "Markeren als belangrijk",
|
"markImportant": "Markeren als belangrijk",
|
||||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||||
"lastUpdatedUnknown": "Nog niet gestart",
|
"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.",
|
"shareQrDialogSubtitle": "Scan de QR-code of kopieer de link om de taken van dit dossier te openen.",
|
||||||
"copyShareLink": "Link kopiëren",
|
"copyShareLink": "Link kopiëren",
|
||||||
"shareLinkCopied": "Link gekopieerd naar klembord",
|
"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": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "Dossiertaken",
|
"pageTitle": "Dossiertaken",
|
||||||
|
|||||||
236
frontend/package-lock.json
generated
236
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.13.0",
|
"next-intl": "^4.13.0",
|
||||||
@@ -242,6 +243,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -2271,6 +2281,19 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -2292,6 +2315,13 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -3185,6 +3215,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.7",
|
||||||
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
|
||||||
@@ -3335,6 +3375,26 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -3413,6 +3473,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3428,6 +3500,16 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -3714,6 +3796,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4474,6 +4566,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -4484,6 +4587,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -4898,6 +5007,20 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz",
|
"resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz",
|
||||||
@@ -4994,6 +5117,12 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "3.5.11"
|
"@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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -5524,6 +5653,23 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"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"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6462,6 +6624,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6590,6 +6759,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||||
@@ -6743,6 +6922,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
@@ -6822,6 +7008,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -7167,6 +7363,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||||
@@ -7387,6 +7603,16 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"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"
|
"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": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "37.3.6",
|
"version": "37.3.6",
|
||||||
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.13.0",
|
"next-intl": "^4.13.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LabCaseDetail } from '@/types/cases';
|
import type { LabCaseDetail } from '@/types/cases';
|
||||||
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
||||||
import { formatAppDateTime } from '@/lib/i18n/format';
|
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||||
|
import type { FdiToothId } from '@/types/treatment';
|
||||||
|
|
||||||
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
@@ -13,23 +14,46 @@ export function formatCaseDateTime(value: string | null, locale: string) {
|
|||||||
|
|
||||||
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
||||||
if (labCase.toothProsthesis.length > 0) {
|
if (labCase.toothProsthesis.length > 0) {
|
||||||
const byCode = new Map<string, string[]>();
|
const byKey = new Map<string, CaseToothChartProsthesisRow>();
|
||||||
for (const row of labCase.toothProsthesis) {
|
for (const row of labCase.toothProsthesis) {
|
||||||
const teeth = byCode.get(row.prosthesisTypeCode) ?? [];
|
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
||||||
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
|
const key = `${selectionGroupId}::${row.prosthesisTypeCode}`;
|
||||||
byCode.set(row.prosthesisTypeCode, teeth);
|
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,
|
return [...byKey.values()].map((row) => ({
|
||||||
teeth,
|
...row,
|
||||||
|
connected: Boolean(row.selectionGroupId) && row.teeth.length > 1,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return labCase.tasksByTooth.map((g) => ({
|
return labCase.tasksByTooth.map((g) => ({
|
||||||
prosthesisTypeCode: g.prosthesisTypeCode,
|
prosthesisTypeCode: g.prosthesisTypeCode,
|
||||||
teeth: g.teeth,
|
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<FdiToothId> {
|
||||||
|
const set = new Set<FdiToothId>();
|
||||||
|
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) {
|
export function latestCaseAttachment(labCase: LabCaseDetail) {
|
||||||
if (!labCase.attachments.length) return null;
|
if (!labCase.attachments.length) return null;
|
||||||
return [...labCase.attachments].sort(
|
return [...labCase.attachments].sort(
|
||||||
|
|||||||
139
frontend/src/components/lab/caseSheetPdf.ts
Normal file
139
frontend/src/components/lab/caseSheetPdf.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { Download, MessageSquare } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
||||||
@@ -22,11 +23,15 @@ import {
|
|||||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import {
|
import {
|
||||||
|
buildCaseConnectedTeeth,
|
||||||
buildCaseProsthesisRows,
|
buildCaseProsthesisRows,
|
||||||
formatCaseDateTime,
|
formatCaseDateTime,
|
||||||
formatPatientName,
|
formatPatientName,
|
||||||
latestCaseAttachment,
|
latestCaseAttachment,
|
||||||
} from '@/components/lab/caseDetailUtils';
|
} 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 { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
||||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ export interface CaseDetailPanelProps {
|
|||||||
canEditImportant?: boolean;
|
canEditImportant?: boolean;
|
||||||
updatingImportant?: boolean;
|
updatingImportant?: boolean;
|
||||||
onImportantChange?: (checked: boolean) => void;
|
onImportantChange?: (checked: boolean) => void;
|
||||||
|
updatingExternalCode?: boolean;
|
||||||
|
onExternalCodeChange?: (externalCode: string | null) => void;
|
||||||
commentsSection?: ReactNode;
|
commentsSection?: ReactNode;
|
||||||
assignableStaff?: AssignableTaskStaff[];
|
assignableStaff?: AssignableTaskStaff[];
|
||||||
canAssignTasks?: boolean;
|
canAssignTasks?: boolean;
|
||||||
@@ -85,6 +92,8 @@ export function CaseDetailPanel({
|
|||||||
canEditImportant = false,
|
canEditImportant = false,
|
||||||
updatingImportant = false,
|
updatingImportant = false,
|
||||||
onImportantChange,
|
onImportantChange,
|
||||||
|
updatingExternalCode = false,
|
||||||
|
onExternalCodeChange,
|
||||||
commentsSection,
|
commentsSection,
|
||||||
assignableStaff = [],
|
assignableStaff = [],
|
||||||
canAssignTasks = false,
|
canAssignTasks = false,
|
||||||
@@ -92,9 +101,18 @@ export function CaseDetailPanel({
|
|||||||
onAssignTask,
|
onAssignTask,
|
||||||
}: CaseDetailPanelProps) {
|
}: CaseDetailPanelProps) {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
|
const tTreatment = useTranslations('treatment');
|
||||||
|
const intlLocale = useLocale();
|
||||||
|
const toast = useToast();
|
||||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||||
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
|
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
|
||||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||||
|
const [externalCodeDraft, setExternalCodeDraft] = useState(labCase.externalCode ?? '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExternalCodeDraft(labCase.externalCode ?? '');
|
||||||
|
}, [labCase.id, labCase.externalCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void prosthesisCatalogApi
|
void prosthesisCatalogApi
|
||||||
@@ -104,8 +122,40 @@ export function CaseDetailPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
||||||
|
const connectedTeeth = useMemo(() => buildCaseConnectedTeeth(labCase), [labCase]);
|
||||||
const previewAttachment = useMemo(() => latestCaseAttachment(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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
|
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
|
||||||
@@ -144,14 +194,36 @@ export function CaseDetailPanel({
|
|||||||
|
|
||||||
<div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
|
<div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
|
||||||
{canEditImportant ? (
|
{canEditImportant ? (
|
||||||
|
<div className="flex w-full max-w-sm items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={labCase.isImportant ?? false}
|
checked={labCase.isImportant ?? false}
|
||||||
disabled={updatingImportant}
|
disabled={updatingImportant}
|
||||||
label={t('markCaseImportant')}
|
label={t('markCaseImportant')}
|
||||||
|
labelPosition="start"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onChange={(checked) => onImportantChange?.(checked)}
|
onChange={(checked) => onImportantChange?.(checked)}
|
||||||
/>
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={externalCodeDraft}
|
||||||
|
placeholder={t('externalCodePlaceholder')}
|
||||||
|
disabled={updatingExternalCode}
|
||||||
|
maxLength={64}
|
||||||
|
aria-label={t('externalCodePlaceholder')}
|
||||||
|
onChange={(e) => setExternalCodeDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const next = externalCodeDraft.trim() || null;
|
||||||
|
const prev = labCase.externalCode?.trim() || null;
|
||||||
|
if (next === prev) return;
|
||||||
|
onExternalCodeChange?.(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : labCase.externalCode?.trim() ? (
|
||||||
|
<p className="text-sm text-text-muted">{labCase.externalCode.trim()}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex w-full sm:w-auto flex-wrap items-center justify-end gap-2">
|
||||||
{showCommentsButton && onCommentsClick ? (
|
{showCommentsButton && onCommentsClick ? (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
|
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
|
||||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||||
@@ -160,6 +232,17 @@ export function CaseDetailPanel({
|
|||||||
: t('showComments')}
|
: t('showComments')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={downloadingPdf}
|
||||||
|
onClick={() => void handleDownloadCaseSheet()}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 me-1.5" />
|
||||||
|
{t('downloadCaseSheet')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
|
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
|
||||||
<div className="flex flex-row items-center gap-2 shrink-0">
|
<div className="flex flex-row items-center gap-2 shrink-0">
|
||||||
{previewAttachment && labCase.attachments.length > 0 ? (
|
{previewAttachment && labCase.attachments.length > 0 ? (
|
||||||
@@ -192,6 +275,7 @@ export function CaseDetailPanel({
|
|||||||
<CaseToothChartPanel
|
<CaseToothChartPanel
|
||||||
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
||||||
prosthesisRows={prosthesisRows}
|
prosthesisRows={prosthesisRows}
|
||||||
|
connectedTeeth={connectedTeeth}
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -201,8 +285,11 @@ export function CaseDetailPanel({
|
|||||||
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
|
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
|
||||||
<div className="rounded-md bg-background border border-border p-2 text-sm">
|
<div className="rounded-md bg-background border border-border p-2 text-sm">
|
||||||
<div className="font-medium">{treatmentLabel(labCase.detail.treatmentType)}</div>
|
<div className="font-medium">{treatmentLabel(labCase.detail.treatmentType)}</div>
|
||||||
<div className="text-text-muted">
|
<div className="flex flex-wrap items-center gap-2 text-text-muted">
|
||||||
|
<span>
|
||||||
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
|
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
|
||||||
|
</span>
|
||||||
|
{connectedTeeth.size > 0 ? <ConnectedSelectionBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
{labCase.detail.comment ? (
|
{labCase.detail.comment ? (
|
||||||
<div className="text-text-muted mt-1">{labCase.detail.comment}</div>
|
<div className="text-text-muted mt-1">{labCase.detail.comment}</div>
|
||||||
@@ -218,7 +305,7 @@ export function CaseDetailPanel({
|
|||||||
) : (
|
) : (
|
||||||
labCase.tasksByTooth.map((group) => (
|
labCase.tasksByTooth.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
key={`${group.treatmentDetailId}-${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}`}
|
||||||
className="rounded-md border border-border p-3 space-y-2"
|
className="rounded-md border border-border p-3 space-y-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -238,6 +325,7 @@ export function CaseDetailPanel({
|
|||||||
prosthesis: group.prosthesisTypeLabel,
|
prosthesis: group.prosthesisTypeLabel,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
{group.connected ? <ConnectedSelectionBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{group.tasks.map((task) => (
|
{group.tasks.map((task) => (
|
||||||
|
|||||||
168
frontend/src/components/ui/lab/CaseSheetFdiChart.tsx
Normal file
168
frontend/src/components/ui/lab/CaseSheetFdiChart.tsx
Normal file
@@ -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<FdiToothId>;
|
||||||
|
connectedTeeth: ReadonlySet<FdiToothId>;
|
||||||
|
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<Record<FdiToothId, string>> = {};
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
|
||||||
|
>
|
||||||
|
{/* Vertical midline (left/right quadrants) */}
|
||||||
|
<line
|
||||||
|
x1={midX}
|
||||||
|
y1={4}
|
||||||
|
x2={midX}
|
||||||
|
y2={height - 4}
|
||||||
|
stroke="#cbd5e1"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
{/* Horizontal separator (upper/lower arches) */}
|
||||||
|
<line
|
||||||
|
x1={4}
|
||||||
|
y1={midY}
|
||||||
|
x2={width - 4}
|
||||||
|
y2={midY}
|
||||||
|
stroke="#cbd5e1"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchRow
|
||||||
|
teeth={FDI_UPPER_LEFT_TO_RIGHT}
|
||||||
|
selected={selected}
|
||||||
|
connectedTeeth={connectedTeeth}
|
||||||
|
colors={colors}
|
||||||
|
connectedMarksBelow
|
||||||
|
yOffset={0}
|
||||||
|
/>
|
||||||
|
<ArchRow
|
||||||
|
teeth={FDI_LOWER_LEFT_TO_RIGHT}
|
||||||
|
selected={selected}
|
||||||
|
connectedTeeth={connectedTeeth}
|
||||||
|
colors={colors}
|
||||||
|
connectedMarksBelow={false}
|
||||||
|
yOffset={ARCH_H + GAP}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArchRow({
|
||||||
|
teeth,
|
||||||
|
selected,
|
||||||
|
connectedTeeth,
|
||||||
|
colors,
|
||||||
|
connectedMarksBelow,
|
||||||
|
yOffset,
|
||||||
|
}: {
|
||||||
|
teeth: readonly FdiToothId[];
|
||||||
|
selected: ReadonlySet<FdiToothId>;
|
||||||
|
connectedTeeth: ReadonlySet<FdiToothId>;
|
||||||
|
colors: Partial<Record<FdiToothId, string>>;
|
||||||
|
connectedMarksBelow: boolean;
|
||||||
|
yOffset: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<g transform={`translate(0 ${yOffset})`}>
|
||||||
|
{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 (
|
||||||
|
<g key={fdi}>
|
||||||
|
{linkToNext ? (
|
||||||
|
<line
|
||||||
|
x1={x + 8}
|
||||||
|
y1={markY}
|
||||||
|
x2={x + CELL - 8}
|
||||||
|
y2={markY}
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<rect
|
||||||
|
x={x - 14}
|
||||||
|
y={cy - 16}
|
||||||
|
width={28}
|
||||||
|
height={32}
|
||||||
|
rx={6}
|
||||||
|
fill={fill}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={cy + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={11}
|
||||||
|
fontFamily="system-ui, sans-serif"
|
||||||
|
fill="#0f172a"
|
||||||
|
fontWeight={isSelected ? 600 : 400}
|
||||||
|
>
|
||||||
|
{fdi}
|
||||||
|
</text>
|
||||||
|
{isConnected && isSelected ? (
|
||||||
|
<circle cx={x} cy={markY} r={3.5} fill="#475569" />
|
||||||
|
) : null}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx
Normal file
248
frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx
Normal file
@@ -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<FdiToothId>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-case-sheet-root
|
||||||
|
style={{
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: '794px',
|
||||||
|
minHeight: '1123px',
|
||||||
|
padding: '36px 40px',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
color: '#0f172a',
|
||||||
|
fontFamily: 'system-ui, -apple-system, Segoe UI, sans-serif',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
borderBottom: '1px solid #cbd5e1',
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<p style={{ ...muted, margin: 0 }}>Dyolink</p>
|
||||||
|
<h1 style={{ margin: '4px 0 0', fontSize: 24, fontWeight: 600 }}>{labels.title}</h1>
|
||||||
|
</div>
|
||||||
|
{labCase.shareUrl ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
padding: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCode value={labCase.shareUrl} size={72} level="M" fgColor="#0f172a" bgColor="#ffffff" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetaBlock label={labels.orderNumber} value={orderNumber} />
|
||||||
|
<MetaBlock label={labels.orderDate} value={formatCaseDateTime(labCase.sentAt, locale)} />
|
||||||
|
<MetaBlock label={labels.dateDue} value={formatCaseDateTime(labCase.dueDate, locale)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: 16,
|
||||||
|
...rule,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PartyBlock label={labels.sender} title={labCase.clinic.name} />
|
||||||
|
<PartyBlock label={labels.recipient} title={labName} />
|
||||||
|
<PartyBlock
|
||||||
|
label={labels.patient}
|
||||||
|
title={formatPatientName(labCase.patient)}
|
||||||
|
subtitle={`${labels.patientId}: ${labCase.patient.mobile}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={rule}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{labels.prosthesis}</h2>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '8px 0 0', padding: 0 }}>
|
||||||
|
{prosthesisRows.length === 0 ? (
|
||||||
|
<li style={{ color: '#64748b' }}>—</li>
|
||||||
|
) : (
|
||||||
|
prosthesisRows.map((row) => {
|
||||||
|
const fromCatalog = prosthesisCatalog.find(
|
||||||
|
(e) => e.code === row.prosthesisTypeCode,
|
||||||
|
)?.label;
|
||||||
|
const fromTasks = labCase.tasksByTooth.find(
|
||||||
|
(g) => g.prosthesisTypeCode === row.prosthesisTypeCode,
|
||||||
|
)?.prosthesisTypeLabel;
|
||||||
|
const label =
|
||||||
|
fromCatalog?.trim() ||
|
||||||
|
fromTasks?.trim() ||
|
||||||
|
row.prosthesisTypeCode.replace(/_/g, ' ');
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${row.selectionGroupId ?? ''}-${row.prosthesisTypeCode}-${row.teeth.join(',')}`}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 10px',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{label}</span>
|
||||||
|
{row.connected ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: '#334155',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels.connected}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 11, color: '#475569' }}>
|
||||||
|
{labels.teeth}: {formatToothList(row.teeth)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selected.size > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 style={{ margin: '18px 0 0', fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{labels.toothChart}
|
||||||
|
</h2>
|
||||||
|
<section style={rule}>
|
||||||
|
<CaseSheetFdiChart
|
||||||
|
selected={selected}
|
||||||
|
connectedTeeth={connectedTeeth}
|
||||||
|
prosthesisRows={prosthesisRows}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section style={rule}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{labels.comments}</h2>
|
||||||
|
<p style={{ ...body, margin: '8px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{labCase.detail?.comment?.trim() || labels.noComments}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaBlock({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ ...muted, margin: 0 }}>{label}</p>
|
||||||
|
<p style={{ ...body, margin: '2px 0 0', fontWeight: 500 }}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartyBlock({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ ...muted, margin: 0 }}>{label}</p>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 14, fontWeight: 600 }}>{title}</p>
|
||||||
|
{subtitle ? (
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#475569' }}>{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,22 +13,27 @@ export interface CaseToothChartDetail {
|
|||||||
export interface CaseToothChartProsthesisRow {
|
export interface CaseToothChartProsthesisRow {
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
|
connected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaseToothChartPanelProps {
|
interface CaseToothChartPanelProps {
|
||||||
details: CaseToothChartDetail[];
|
details: CaseToothChartDetail[];
|
||||||
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||||
|
/** Teeth in multi-tooth connected bridges (link dots on the chart). */
|
||||||
|
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||||
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
|
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
|
||||||
scale?: number;
|
scale?: number;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
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({
|
export function CaseToothChartPanel({
|
||||||
details,
|
details,
|
||||||
prosthesisRows,
|
prosthesisRows,
|
||||||
|
connectedTeeth,
|
||||||
prosthesisCatalog,
|
prosthesisCatalog,
|
||||||
scale = 1,
|
scale = 1,
|
||||||
compact = true,
|
compact = true,
|
||||||
@@ -55,6 +60,16 @@ export function CaseToothChartPanel({
|
|||||||
return colors;
|
return colors;
|
||||||
}, [prosthesisRows, prosthesisCatalog]);
|
}, [prosthesisRows, prosthesisCatalog]);
|
||||||
|
|
||||||
|
const resolvedConnected = useMemo(() => {
|
||||||
|
if (connectedTeeth) return connectedTeeth;
|
||||||
|
const set = new Set<FdiToothId>();
|
||||||
|
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;
|
if (selected.size === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +78,7 @@ export function CaseToothChartPanel({
|
|||||||
readOnly
|
readOnly
|
||||||
scale={scale}
|
scale={scale}
|
||||||
toothColors={toothColors}
|
toothColors={toothColors}
|
||||||
|
connectedTeeth={resolvedConnected.size > 0 ? resolvedConnected : undefined}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function CasesPage() {
|
|||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [updatingImportant, setUpdatingImportant] = useState(false);
|
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||||
|
const [updatingExternalCode, setUpdatingExternalCode] = useState(false);
|
||||||
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
|
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
|
||||||
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
|
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
|
||||||
const [commentCount, setCommentCount] = useState(0);
|
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`;
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -550,6 +570,8 @@ export function CasesPage() {
|
|||||||
canEditImportant={canEdit}
|
canEditImportant={canEdit}
|
||||||
updatingImportant={updatingImportant}
|
updatingImportant={updatingImportant}
|
||||||
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||||
|
updatingExternalCode={updatingExternalCode}
|
||||||
|
onExternalCodeChange={(code) => void handleCaseExternalCodeChange(code)}
|
||||||
assignableStaff={assignableStaff}
|
assignableStaff={assignableStaff}
|
||||||
canAssignTasks={canEdit}
|
canAssignTasks={canEdit}
|
||||||
assigningTaskId={assigningTaskId}
|
assigningTaskId={assigningTaskId}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type CheckboxProps = {
|
|||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Where the label sits relative to the box. Default: after the box. */
|
||||||
|
labelPosition?: 'start' | 'end';
|
||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -21,6 +23,7 @@ export function Checkbox({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label,
|
label,
|
||||||
|
labelPosition = 'end',
|
||||||
id,
|
id,
|
||||||
className = '',
|
className = '',
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
@@ -39,6 +42,30 @@ export function Checkbox({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const box = (
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
|
||||||
|
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
|
||||||
|
${
|
||||||
|
checked
|
||||||
|
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
|
||||||
|
: 'border-border-strong bg-background-card/90 hover:border-border'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
strokeWidth={3}
|
||||||
|
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
|
||||||
|
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = <span className="text-sm text-text-secondary">{label}</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -63,26 +90,17 @@ export function Checkbox({
|
|||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span
|
{labelPosition === 'start' ? (
|
||||||
className={`
|
<>
|
||||||
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
|
{text}
|
||||||
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
|
{box}
|
||||||
${
|
</>
|
||||||
checked
|
) : (
|
||||||
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
|
<>
|
||||||
: 'border-border-strong bg-background-card/90 hover:border-border'
|
{box}
|
||||||
}
|
{text}
|
||||||
`}
|
</>
|
||||||
aria-hidden
|
)}
|
||||||
>
|
|
||||||
<Check
|
|
||||||
strokeWidth={3}
|
|
||||||
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
|
|
||||||
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-text-secondary">{label}</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const casesApi = {
|
|||||||
return response.data;
|
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<Blob> => {
|
getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise<Blob> => {
|
||||||
const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
|
const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface LabCaseTaskGroup {
|
|||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
prosthesisTypeLabel: string;
|
prosthesisTypeLabel: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
|
connected?: boolean;
|
||||||
tasks: LabCaseTask[];
|
tasks: LabCaseTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +94,8 @@ export interface LabCaseDetail {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
isOverdue: boolean;
|
isOverdue: boolean;
|
||||||
isImportant: 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 };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -111,6 +115,7 @@ export interface LabCaseDetail {
|
|||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
}>;
|
}>;
|
||||||
attachments: LabCaseAttachmentMeta[];
|
attachments: LabCaseAttachmentMeta[];
|
||||||
sends: Array<{
|
sends: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user