259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
'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>
|
|
);
|
|
}
|