Files
dyolink/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx

259 lines
8.0 KiB
TypeScript
Raw Normal View History

'use client';
import QRCode from 'react-qr-code';
import { useTranslations } from 'next-intl';
import { CaseSheetFdiChart } from '@/components/ui/lab/CaseSheetFdiChart';
import {
buildCaseConnectedTeeth,
buildCaseProsthesisRows,
formatCaseDateTime,
formatPatientName,
} from '@/components/lab/caseDetailUtils';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
import type { LabCaseDetail } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId } from '@/types/treatment';
import { BrandLockup } from '@/components/ui/shared/BrandLockup';
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;
archUpper: string;
archLower: 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 tProsthesis = useTranslations('prosthesis');
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,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'center',
color: '#0f172a',
fontSize: 22,
lineHeight: 1,
marginBottom: 16,
}}
>
<BrandLockup />
</div>
<header
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
borderBottom: '1px solid #cbd5e1',
paddingBottom: 16,
}}
>
<h1 style={{ margin: 0, minWidth: 0, flex: 1, fontSize: 24, fontWeight: 600 }}>{labels.title}</h1>
{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 label = prosthesisJobPathLabel(
row.prosthesisTypeCode,
prosthesisCatalog,
(key) => tProsthesis(key as never),
);
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, { UA: labels.archUpper, LA: labels.archLower })}
</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>
);
}