improvement: pdf generation added to cases feature.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { LabCaseDetail } from '@/types/cases';
|
||||
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
||||
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
|
||||
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
@@ -13,23 +14,46 @@ export function formatCaseDateTime(value: string | null, locale: string) {
|
||||
|
||||
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
||||
if (labCase.toothProsthesis.length > 0) {
|
||||
const byCode = new Map<string, string[]>();
|
||||
const byKey = new Map<string, CaseToothChartProsthesisRow>();
|
||||
for (const row of labCase.toothProsthesis) {
|
||||
const teeth = byCode.get(row.prosthesisTypeCode) ?? [];
|
||||
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
|
||||
byCode.set(row.prosthesisTypeCode, teeth);
|
||||
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
||||
const key = `${selectionGroupId}::${row.prosthesisTypeCode}`;
|
||||
const existing = byKey.get(key);
|
||||
if (existing) {
|
||||
if (!existing.teeth.includes(row.tooth)) existing.teeth.push(row.tooth);
|
||||
} else {
|
||||
byKey.set(key, {
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
teeth: [row.tooth],
|
||||
selectionGroupId: selectionGroupId || undefined,
|
||||
connected: Boolean(selectionGroupId),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
|
||||
prosthesisTypeCode,
|
||||
teeth,
|
||||
return [...byKey.values()].map((row) => ({
|
||||
...row,
|
||||
connected: Boolean(row.selectionGroupId) && row.teeth.length > 1,
|
||||
}));
|
||||
}
|
||||
return labCase.tasksByTooth.map((g) => ({
|
||||
prosthesisTypeCode: g.prosthesisTypeCode,
|
||||
teeth: g.teeth,
|
||||
selectionGroupId: g.selectionGroupId,
|
||||
connected: Boolean(g.connected ?? (g.selectionGroupId && g.teeth.length > 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Teeth that belong to a multi-tooth connected bridge (for FDI chart dots). */
|
||||
export function buildCaseConnectedTeeth(labCase: LabCaseDetail): Set<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) {
|
||||
if (!labCase.attachments.length) return null;
|
||||
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';
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Download, MessageSquare } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
||||
@@ -22,11 +23,15 @@ import {
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import {
|
||||
buildCaseConnectedTeeth,
|
||||
buildCaseProsthesisRows,
|
||||
formatCaseDateTime,
|
||||
formatPatientName,
|
||||
latestCaseAttachment,
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import { downloadCaseSheetPdf } from '@/components/lab/caseSheetPdf';
|
||||
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import type { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
@@ -65,6 +70,8 @@ export interface CaseDetailPanelProps {
|
||||
canEditImportant?: boolean;
|
||||
updatingImportant?: boolean;
|
||||
onImportantChange?: (checked: boolean) => void;
|
||||
updatingExternalCode?: boolean;
|
||||
onExternalCodeChange?: (externalCode: string | null) => void;
|
||||
commentsSection?: ReactNode;
|
||||
assignableStaff?: AssignableTaskStaff[];
|
||||
canAssignTasks?: boolean;
|
||||
@@ -85,6 +92,8 @@ export function CaseDetailPanel({
|
||||
canEditImportant = false,
|
||||
updatingImportant = false,
|
||||
onImportantChange,
|
||||
updatingExternalCode = false,
|
||||
onExternalCodeChange,
|
||||
commentsSection,
|
||||
assignableStaff = [],
|
||||
canAssignTasks = false,
|
||||
@@ -92,9 +101,18 @@ export function CaseDetailPanel({
|
||||
onAssignTask,
|
||||
}: CaseDetailPanelProps) {
|
||||
const t = useTranslations('cases');
|
||||
const tTreatment = useTranslations('treatment');
|
||||
const intlLocale = useLocale();
|
||||
const toast = useToast();
|
||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
|
||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||
const [externalCodeDraft, setExternalCodeDraft] = useState(labCase.externalCode ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setExternalCodeDraft(labCase.externalCode ?? '');
|
||||
}, [labCase.id, labCase.externalCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void prosthesisCatalogApi
|
||||
@@ -104,8 +122,40 @@ export function CaseDetailPanel({
|
||||
}, []);
|
||||
|
||||
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
||||
const connectedTeeth = useMemo(() => buildCaseConnectedTeeth(labCase), [labCase]);
|
||||
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
|
||||
|
||||
async function handleDownloadCaseSheet() {
|
||||
setDownloadingPdf(true);
|
||||
try {
|
||||
await downloadCaseSheetPdf({
|
||||
labCase,
|
||||
locale: intlLocale,
|
||||
prosthesisCatalog,
|
||||
labels: {
|
||||
title: t('caseSheetTitle'),
|
||||
orderNumber: t('caseSheetOrderNumber'),
|
||||
orderDate: t('caseSheetOrderDate'),
|
||||
dateDue: t('caseSheetDateDue'),
|
||||
sender: t('caseSheetSender'),
|
||||
recipient: t('caseSheetRecipient'),
|
||||
patient: t('caseSheetPatient'),
|
||||
patientId: t('caseSheetPatientId'),
|
||||
prosthesis: t('caseSheetProsthesis'),
|
||||
toothChart: t('caseSheetToothChart'),
|
||||
connected: tTreatment('connectedBadge'),
|
||||
teeth: t('teethLabel'),
|
||||
comments: t('caseSheetComments'),
|
||||
noComments: t('caseSheetNoComments'),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.showError(t('errorDownloadCaseSheet'));
|
||||
} finally {
|
||||
setDownloadingPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -144,22 +194,55 @@ export function CaseDetailPanel({
|
||||
|
||||
<div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
|
||||
{canEditImportant ? (
|
||||
<Checkbox
|
||||
checked={labCase.isImportant ?? false}
|
||||
disabled={updatingImportant}
|
||||
label={t('markCaseImportant')}
|
||||
className="shrink-0"
|
||||
onChange={(checked) => onImportantChange?.(checked)}
|
||||
/>
|
||||
<div className="flex w-full max-w-sm items-center gap-3">
|
||||
<Checkbox
|
||||
checked={labCase.isImportant ?? false}
|
||||
disabled={updatingImportant}
|
||||
label={t('markCaseImportant')}
|
||||
labelPosition="start"
|
||||
className="shrink-0"
|
||||
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}
|
||||
{showCommentsButton && onCommentsClick ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
|
||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||
{commentCount > 0
|
||||
? t('commentsCount', { count: commentCount })
|
||||
: t('showComments')}
|
||||
<div className="flex w-full sm:w-auto flex-wrap items-center justify-end gap-2">
|
||||
{showCommentsButton && onCommentsClick ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
|
||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||
{commentCount > 0
|
||||
? t('commentsCount', { count: commentCount })
|
||||
: t('showComments')}
|
||||
</Button>
|
||||
) : 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>
|
||||
) : null}
|
||||
</div>
|
||||
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
|
||||
<div className="flex flex-row items-center gap-2 shrink-0">
|
||||
{previewAttachment && labCase.attachments.length > 0 ? (
|
||||
@@ -192,6 +275,7 @@ export function CaseDetailPanel({
|
||||
<CaseToothChartPanel
|
||||
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
||||
prosthesisRows={prosthesisRows}
|
||||
connectedTeeth={connectedTeeth}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -201,8 +285,11 @@ export function CaseDetailPanel({
|
||||
<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="font-medium">{treatmentLabel(labCase.detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
|
||||
<div className="flex flex-wrap items-center gap-2 text-text-muted">
|
||||
<span>
|
||||
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
|
||||
</span>
|
||||
{connectedTeeth.size > 0 ? <ConnectedSelectionBadge /> : null}
|
||||
</div>
|
||||
{labCase.detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{labCase.detail.comment}</div>
|
||||
@@ -218,7 +305,7 @@ export function CaseDetailPanel({
|
||||
) : (
|
||||
labCase.tasksByTooth.map((group) => (
|
||||
<div
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
key={`${group.treatmentDetailId}-${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -238,6 +325,7 @@ export function CaseDetailPanel({
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
{group.connected ? <ConnectedSelectionBadge /> : null}
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{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 {
|
||||
teeth: string[];
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId?: string;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
interface CaseToothChartPanelProps {
|
||||
details: CaseToothChartDetail[];
|
||||
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||
/** Teeth in multi-tooth connected bridges (link dots on the chart). */
|
||||
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
|
||||
scale?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */
|
||||
/** Read-only FDI chart for lab case detail — prosthesis-type glow + connected bridge dots. */
|
||||
export function CaseToothChartPanel({
|
||||
details,
|
||||
prosthesisRows,
|
||||
connectedTeeth,
|
||||
prosthesisCatalog,
|
||||
scale = 1,
|
||||
compact = true,
|
||||
@@ -55,6 +60,16 @@ export function CaseToothChartPanel({
|
||||
return colors;
|
||||
}, [prosthesisRows, prosthesisCatalog]);
|
||||
|
||||
const resolvedConnected = useMemo(() => {
|
||||
if (connectedTeeth) return connectedTeeth;
|
||||
const set = new Set<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;
|
||||
|
||||
return (
|
||||
@@ -63,6 +78,7 @@ export function CaseToothChartPanel({
|
||||
readOnly
|
||||
scale={scale}
|
||||
toothColors={toothColors}
|
||||
connectedTeeth={resolvedConnected.size > 0 ? resolvedConnected : undefined}
|
||||
compact={compact}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
@@ -75,6 +75,7 @@ export function CasesPage() {
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||
const [updatingExternalCode, setUpdatingExternalCode] = useState(false);
|
||||
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
|
||||
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
|
||||
const [commentCount, setCommentCount] = useState(0);
|
||||
@@ -330,6 +331,25 @@ export function CasesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCaseExternalCodeChange(externalCode: string | null) {
|
||||
if (!selectedCaseId || !canEdit || !selectedCase) return;
|
||||
|
||||
const previousCase = selectedCase;
|
||||
setSelectedCase({ ...selectedCase, externalCode });
|
||||
|
||||
setUpdatingExternalCode(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
const response = await casesApi.setCaseExternalCode(selectedCaseId, externalCode);
|
||||
setSelectedCase(response.data);
|
||||
} catch (error: unknown) {
|
||||
setSelectedCase(previousCase);
|
||||
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateExternalCode')));
|
||||
} finally {
|
||||
setUpdatingExternalCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`;
|
||||
|
||||
return (
|
||||
@@ -550,6 +570,8 @@ export function CasesPage() {
|
||||
canEditImportant={canEdit}
|
||||
updatingImportant={updatingImportant}
|
||||
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||
updatingExternalCode={updatingExternalCode}
|
||||
onExternalCodeChange={(code) => void handleCaseExternalCodeChange(code)}
|
||||
assignableStaff={assignableStaff}
|
||||
canAssignTasks={canEdit}
|
||||
assigningTaskId={assigningTaskId}
|
||||
|
||||
@@ -8,6 +8,8 @@ type CheckboxProps = {
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
/** Where the label sits relative to the box. Default: after the box. */
|
||||
labelPosition?: 'start' | 'end';
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
@@ -21,6 +23,7 @@ export function Checkbox({
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
labelPosition = 'end',
|
||||
id,
|
||||
className = '',
|
||||
}: CheckboxProps) {
|
||||
@@ -39,6 +42,30 @@ export function Checkbox({
|
||||
}
|
||||
};
|
||||
|
||||
const box = (
|
||||
<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 (
|
||||
<label
|
||||
id={inputId}
|
||||
@@ -63,26 +90,17 @@ export function Checkbox({
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
{labelPosition === 'start' ? (
|
||||
<>
|
||||
{text}
|
||||
{box}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{box}
|
||||
{text}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ export const casesApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setCaseExternalCode: async (
|
||||
caseId: string,
|
||||
externalCode: string | null,
|
||||
): Promise<{ success: boolean; data: LabCaseDetail }> => {
|
||||
const response = await apiClient.patch(`/cases/${caseId}/external-code`, { externalCode });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise<Blob> => {
|
||||
const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface LabCaseTaskGroup {
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
selectionGroupId?: string;
|
||||
connected?: boolean;
|
||||
tasks: LabCaseTask[];
|
||||
}
|
||||
|
||||
@@ -92,6 +94,8 @@ export interface LabCaseDetail {
|
||||
dueDate: string | null;
|
||||
isOverdue: boolean;
|
||||
isImportant: boolean;
|
||||
/** Optional external lab-app code (e.g. exocad); used as PDF order number when set. */
|
||||
externalCode?: string | null;
|
||||
clinic: { id: string; name: string };
|
||||
patient: {
|
||||
id: string;
|
||||
@@ -111,6 +115,7 @@ export interface LabCaseDetail {
|
||||
treatmentDetailId: string;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId?: string;
|
||||
}>;
|
||||
attachments: LabCaseAttachmentMeta[];
|
||||
sends: Array<{
|
||||
|
||||
Reference in New Issue
Block a user