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();
}
}

View File

@@ -1,11 +1,12 @@
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { Download, MessageSquare } from 'lucide-react';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Input } from '@/components/ui/shared/Input';
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
@@ -22,11 +23,15 @@ import {
} from '@/components/treatment/prosthesisTypeDisplay';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
buildCaseConnectedTeeth,
buildCaseProsthesisRows,
formatCaseDateTime,
formatPatientName,
latestCaseAttachment,
} from '@/components/lab/caseDetailUtils';
import { downloadCaseSheetPdf } from '@/components/lab/caseSheetPdf';
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
import { useToast } from '@/lib/hooks/useToast';
import type { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -65,6 +70,8 @@ export interface CaseDetailPanelProps {
canEditImportant?: boolean;
updatingImportant?: boolean;
onImportantChange?: (checked: boolean) => void;
updatingExternalCode?: boolean;
onExternalCodeChange?: (externalCode: string | null) => void;
commentsSection?: ReactNode;
assignableStaff?: AssignableTaskStaff[];
canAssignTasks?: boolean;
@@ -85,6 +92,8 @@ export function CaseDetailPanel({
canEditImportant = false,
updatingImportant = false,
onImportantChange,
updatingExternalCode = false,
onExternalCodeChange,
commentsSection,
assignableStaff = [],
canAssignTasks = false,
@@ -92,9 +101,18 @@ export function CaseDetailPanel({
onAssignTask,
}: CaseDetailPanelProps) {
const t = useTranslations('cases');
const tTreatment = useTranslations('treatment');
const intlLocale = useLocale();
const toast = useToast();
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [externalCodeDraft, setExternalCodeDraft] = useState(labCase.externalCode ?? '');
useEffect(() => {
setExternalCodeDraft(labCase.externalCode ?? '');
}, [labCase.id, labCase.externalCode]);
useEffect(() => {
void prosthesisCatalogApi
@@ -104,8 +122,40 @@ export function CaseDetailPanel({
}, []);
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
const connectedTeeth = useMemo(() => buildCaseConnectedTeeth(labCase), [labCase]);
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
async function handleDownloadCaseSheet() {
setDownloadingPdf(true);
try {
await downloadCaseSheetPdf({
labCase,
locale: intlLocale,
prosthesisCatalog,
labels: {
title: t('caseSheetTitle'),
orderNumber: t('caseSheetOrderNumber'),
orderDate: t('caseSheetOrderDate'),
dateDue: t('caseSheetDateDue'),
sender: t('caseSheetSender'),
recipient: t('caseSheetRecipient'),
patient: t('caseSheetPatient'),
patientId: t('caseSheetPatientId'),
prosthesis: t('caseSheetProsthesis'),
toothChart: t('caseSheetToothChart'),
connected: tTreatment('connectedBadge'),
teeth: t('teethLabel'),
comments: t('caseSheetComments'),
noComments: t('caseSheetNoComments'),
},
});
} catch {
toast.showError(t('errorDownloadCaseSheet'));
} finally {
setDownloadingPdf(false);
}
}
return (
<div className="space-y-4">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
@@ -144,22 +194,55 @@ export function CaseDetailPanel({
<div className="flex w-full sm:w-auto shrink-0 flex-col items-end gap-2">
{canEditImportant ? (
<Checkbox
checked={labCase.isImportant ?? false}
disabled={updatingImportant}
label={t('markCaseImportant')}
className="shrink-0"
onChange={(checked) => onImportantChange?.(checked)}
/>
<div className="flex w-full max-w-sm items-center gap-3">
<Checkbox
checked={labCase.isImportant ?? false}
disabled={updatingImportant}
label={t('markCaseImportant')}
labelPosition="start"
className="shrink-0"
onChange={(checked) => onImportantChange?.(checked)}
/>
<div className="min-w-0 flex-1">
<Input
value={externalCodeDraft}
placeholder={t('externalCodePlaceholder')}
disabled={updatingExternalCode}
maxLength={64}
aria-label={t('externalCodePlaceholder')}
onChange={(e) => setExternalCodeDraft(e.target.value)}
onBlur={() => {
const next = externalCodeDraft.trim() || null;
const prev = labCase.externalCode?.trim() || null;
if (next === prev) return;
onExternalCodeChange?.(next);
}}
/>
</div>
</div>
) : labCase.externalCode?.trim() ? (
<p className="text-sm text-text-muted">{labCase.externalCode.trim()}</p>
) : null}
{showCommentsButton && onCommentsClick ? (
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
<div className="flex w-full sm:w-auto flex-wrap items-center justify-end gap-2">
{showCommentsButton && onCommentsClick ? (
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
<MessageSquare className="h-4 w-4 me-1.5" />
{commentCount > 0
? t('commentsCount', { count: commentCount })
: t('showComments')}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
isLoading={downloadingPdf}
onClick={() => void handleDownloadCaseSheet()}
>
<Download className="h-4 w-4 me-1.5" />
{t('downloadCaseSheet')}
</Button>
) : null}
</div>
{labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? (
<div className="flex flex-row items-center gap-2 shrink-0">
{previewAttachment && labCase.attachments.length > 0 ? (
@@ -192,6 +275,7 @@ export function CaseDetailPanel({
<CaseToothChartPanel
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
prosthesisRows={prosthesisRows}
connectedTeeth={connectedTeeth}
prosthesisCatalog={prosthesisCatalog}
className="w-full"
/>
@@ -201,8 +285,11 @@ export function CaseDetailPanel({
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
<div className="rounded-md bg-background border border-border p-2 text-sm">
<div className="font-medium">{treatmentLabel(labCase.detail.treatmentType)}</div>
<div className="text-text-muted">
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
<div className="flex flex-wrap items-center gap-2 text-text-muted">
<span>
{t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'}
</span>
{connectedTeeth.size > 0 ? <ConnectedSelectionBadge /> : null}
</div>
{labCase.detail.comment ? (
<div className="text-text-muted mt-1">{labCase.detail.comment}</div>
@@ -218,7 +305,7 @@ export function CaseDetailPanel({
) : (
labCase.tasksByTooth.map((group) => (
<div
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
key={`${group.treatmentDetailId}-${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
@@ -238,6 +325,7 @@ export function CaseDetailPanel({
prosthesis: group.prosthesisTypeLabel,
})}
</span>
{group.connected ? <ConnectedSelectionBadge /> : null}
</div>
<ul className="space-y-2">
{group.tasks.map((task) => (

View File

@@ -0,0 +1,168 @@
'use client';
import {
FDI_LOWER_LEFT_TO_RIGHT,
FDI_UPPER_LEFT_TO_RIGHT,
} from '@/components/treatment/fdiToothMeta';
import {
prosthesisTypeColor,
prosthesisTypeColorFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId } from '@/types/treatment';
type CaseSheetFdiChartProps = {
selected: ReadonlySet<FdiToothId>;
connectedTeeth: ReadonlySet<FdiToothId>;
prosthesisRows: CaseToothChartProsthesisRow[];
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
};
const CELL = 42;
const ARCH_H = 72;
const GAP = 10;
/**
* PDF-safe FDI preview: only hex/rgb colors (no Tailwind / CSS variables).
* html2canvas cannot parse modern `lab()` / `oklch()` from app stylesheets.
*/
export function CaseSheetFdiChart({
selected,
connectedTeeth,
prosthesisRows,
prosthesisCatalog,
}: CaseSheetFdiChartProps) {
const colors: Partial<Record<FdiToothId, string>> = {};
prosthesisRows.forEach((row, index) => {
const color = prosthesisCatalog?.length
? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog)
: prosthesisTypeColor(row.prosthesisTypeCode, index);
for (const tooth of row.teeth) {
colors[tooth as FdiToothId] = color;
}
});
const width = FDI_UPPER_LEFT_TO_RIGHT.length * CELL;
const height = ARCH_H * 2 + GAP;
const midX = width / 2;
const midY = ARCH_H + GAP / 2;
return (
<svg
width="100%"
viewBox={`0 0 ${width} ${height}`}
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
>
{/* Vertical midline (left/right quadrants) */}
<line
x1={midX}
y1={4}
x2={midX}
y2={height - 4}
stroke="#cbd5e1"
strokeWidth={1}
/>
{/* Horizontal separator (upper/lower arches) */}
<line
x1={4}
y1={midY}
x2={width - 4}
y2={midY}
stroke="#cbd5e1"
strokeWidth={1}
/>
<ArchRow
teeth={FDI_UPPER_LEFT_TO_RIGHT}
selected={selected}
connectedTeeth={connectedTeeth}
colors={colors}
connectedMarksBelow
yOffset={0}
/>
<ArchRow
teeth={FDI_LOWER_LEFT_TO_RIGHT}
selected={selected}
connectedTeeth={connectedTeeth}
colors={colors}
connectedMarksBelow={false}
yOffset={ARCH_H + GAP}
/>
</svg>
);
}
function ArchRow({
teeth,
selected,
connectedTeeth,
colors,
connectedMarksBelow,
yOffset,
}: {
teeth: readonly FdiToothId[];
selected: ReadonlySet<FdiToothId>;
connectedTeeth: ReadonlySet<FdiToothId>;
colors: Partial<Record<FdiToothId, string>>;
connectedMarksBelow: boolean;
yOffset: number;
}) {
return (
<g transform={`translate(0 ${yOffset})`}>
{teeth.map((fdi, index) => {
const x = index * CELL + CELL / 2;
const isSelected = selected.has(fdi);
const isConnected = connectedTeeth.has(fdi);
const next = teeth[index + 1];
const linkToNext = Boolean(
isConnected && next && connectedTeeth.has(next) && selected.has(next),
);
const fill = isSelected ? (colors[fdi] ?? '#94a3b8') : '#f8fafc';
const stroke = isSelected ? '#334155' : '#cbd5e1';
const cy = connectedMarksBelow ? 28 : 44;
const markY = connectedMarksBelow ? 58 : 14;
return (
<g key={fdi}>
{linkToNext ? (
<line
x1={x + 8}
y1={markY}
x2={x + CELL - 8}
y2={markY}
stroke="#64748b"
strokeWidth={2}
strokeLinecap="round"
/>
) : null}
<rect
x={x - 14}
y={cy - 16}
width={28}
height={32}
rx={6}
fill={fill}
stroke={stroke}
strokeWidth={1.5}
/>
<text
x={x}
y={cy + 4}
textAnchor="middle"
fontSize={11}
fontFamily="system-ui, sans-serif"
fill="#0f172a"
fontWeight={isSelected ? 600 : 400}
>
{fdi}
</text>
{isConnected && isSelected ? (
<circle cx={x} cy={markY} r={3.5} fill="#475569" />
) : null}
</g>
);
})}
</g>
);
}

View 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>
);
}

View File

@@ -13,22 +13,27 @@ export interface CaseToothChartDetail {
export interface CaseToothChartProsthesisRow {
teeth: string[];
prosthesisTypeCode: string;
selectionGroupId?: string;
connected?: boolean;
}
interface CaseToothChartPanelProps {
details: CaseToothChartDetail[];
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
prosthesisRows: CaseToothChartProsthesisRow[];
/** Teeth in multi-tooth connected bridges (link dots on the chart). */
connectedTeeth?: ReadonlySet<FdiToothId>;
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
scale?: number;
compact?: boolean;
className?: string;
}
/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */
/** Read-only FDI chart for lab case detail — prosthesis-type glow + connected bridge dots. */
export function CaseToothChartPanel({
details,
prosthesisRows,
connectedTeeth,
prosthesisCatalog,
scale = 1,
compact = true,
@@ -55,6 +60,16 @@ export function CaseToothChartPanel({
return colors;
}, [prosthesisRows, prosthesisCatalog]);
const resolvedConnected = useMemo(() => {
if (connectedTeeth) return connectedTeeth;
const set = new Set<FdiToothId>();
for (const row of prosthesisRows) {
if (!row.connected || row.teeth.length < 2) continue;
for (const tooth of row.teeth) set.add(tooth as FdiToothId);
}
return set;
}, [connectedTeeth, prosthesisRows]);
if (selected.size === 0) return null;
return (
@@ -63,6 +78,7 @@ export function CaseToothChartPanel({
readOnly
scale={scale}
toothColors={toothColors}
connectedTeeth={resolvedConnected.size > 0 ? resolvedConnected : undefined}
compact={compact}
className={className}
/>

View File

@@ -75,6 +75,7 @@ export function CasesPage() {
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingImportant, setUpdatingImportant] = useState(false);
const [updatingExternalCode, setUpdatingExternalCode] = useState(false);
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
const [commentCount, setCommentCount] = useState(0);
@@ -330,6 +331,25 @@ export function CasesPage() {
}
}
async function handleCaseExternalCodeChange(externalCode: string | null) {
if (!selectedCaseId || !canEdit || !selectedCase) return;
const previousCase = selectedCase;
setSelectedCase({ ...selectedCase, externalCode });
setUpdatingExternalCode(true);
toast.setError('');
try {
const response = await casesApi.setCaseExternalCode(selectedCaseId, externalCode);
setSelectedCase(response.data);
} catch (error: unknown) {
setSelectedCase(previousCase);
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateExternalCode')));
} finally {
setUpdatingExternalCode(false);
}
}
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`;
return (
@@ -550,6 +570,8 @@ export function CasesPage() {
canEditImportant={canEdit}
updatingImportant={updatingImportant}
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
updatingExternalCode={updatingExternalCode}
onExternalCodeChange={(code) => void handleCaseExternalCodeChange(code)}
assignableStaff={assignableStaff}
canAssignTasks={canEdit}
assigningTaskId={assigningTaskId}

View File

@@ -8,6 +8,8 @@ type CheckboxProps = {
onChange: (checked: boolean) => void;
disabled?: boolean;
label: string;
/** Where the label sits relative to the box. Default: after the box. */
labelPosition?: 'start' | 'end';
id?: string;
className?: string;
};
@@ -21,6 +23,7 @@ export function Checkbox({
onChange,
disabled = false,
label,
labelPosition = 'end',
id,
className = '',
}: CheckboxProps) {
@@ -39,6 +42,30 @@ export function Checkbox({
}
};
const box = (
<span
className={`
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
${
checked
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
: 'border-border-strong bg-background-card/90 hover:border-border'
}
`}
aria-hidden
>
<Check
strokeWidth={3}
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
}`}
/>
</span>
);
const text = <span className="text-sm text-text-secondary">{label}</span>;
return (
<label
id={inputId}
@@ -63,26 +90,17 @@ export function Checkbox({
${className}
`}
>
<span
className={`
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
${
checked
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
: 'border-border-strong bg-background-card/90 hover:border-border'
}
`}
aria-hidden
>
<Check
strokeWidth={3}
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
}`}
/>
</span>
<span className="text-sm text-text-secondary">{label}</span>
{labelPosition === 'start' ? (
<>
{text}
{box}
</>
) : (
<>
{box}
{text}
</>
)}
</label>
);
}

View File

@@ -49,6 +49,14 @@ export const casesApi = {
return response.data;
},
setCaseExternalCode: async (
caseId: string,
externalCode: string | null,
): Promise<{ success: boolean; data: LabCaseDetail }> => {
const response = await apiClient.patch(`/cases/${caseId}/external-code`, { externalCode });
return response.data;
},
getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise<Blob> => {
const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
responseType: 'blob',

View File

@@ -63,6 +63,8 @@ export interface LabCaseTaskGroup {
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
selectionGroupId?: string;
connected?: boolean;
tasks: LabCaseTask[];
}
@@ -92,6 +94,8 @@ export interface LabCaseDetail {
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
/** Optional external lab-app code (e.g. exocad); used as PDF order number when set. */
externalCode?: string | null;
clinic: { id: string; name: string };
patient: {
id: string;
@@ -111,6 +115,7 @@ export interface LabCaseDetail {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
}>;
attachments: LabCaseAttachmentMeta[];
sends: Array<{