improvement: pdf generation added to cases feature.
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user