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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user