improvement: pdf generation added to cases feature.

This commit is contained in:
2026-07-18 22:00:44 +03:30
parent 408b2c3329
commit 538121d653
23 changed files with 1206 additions and 63 deletions

View File

@@ -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(

View 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 A3A6).
*
* 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();
}
}