improvement: attachment selection for lab dispatch added. attachment preview added to cases feature.

This commit is contained in:
2026-07-07 18:43:10 +03:30
parent b2d40b3e97
commit 86b1e3afff
25 changed files with 767 additions and 84 deletions

View File

@@ -0,0 +1,16 @@
-- Per-shipment attachment selection: only checked files are visible to the lab.
CREATE TABLE "lab_case_attachments" (
"labCaseId" TEXT NOT NULL,
"attachmentId" TEXT NOT NULL,
CONSTRAINT "lab_case_attachments_pkey" PRIMARY KEY ("labCaseId", "attachmentId")
);
CREATE INDEX "lab_case_attachments_attachmentId_idx" ON "lab_case_attachments"("attachmentId");
ALTER TABLE "lab_case_attachments"
ADD CONSTRAINT "lab_case_attachments_labCaseId_fkey"
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_attachments"
ADD CONSTRAINT "lab_case_attachments_attachmentId_fkey"
FOREIGN KEY ("attachmentId") REFERENCES "treatment_detail_attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -23,6 +23,7 @@ const prisma = new PrismaClient();
const TABLES_IN_ORDER = [
'lab_case_task_status_events',
'lab_case_comments',
'lab_case_attachments',
'lab_case_tasks',
'lab_case_sends',
'lab_case_tooth_prosthesis',
@@ -31,6 +32,7 @@ const TABLES_IN_ORDER = [
'treatment_detail_attachments',
'treatment_details',
'treatments',
'appointments',
];
async function tableExists(table: string): Promise<boolean> {

View File

@@ -179,6 +179,7 @@ model TreatmentDetailAttachment {
storagePath String
detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade)
labCaseLinks LabCaseAttachment[]
createdAt DateTime @default(now())
@@ -201,11 +202,24 @@ model LabCase {
tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
comments LabCaseComment[]
attachments LabCaseAttachment[]
@@index([treatmentId, sortOrder])
@@map("lab_cases")
}
model LabCaseAttachment {
labCaseId String
attachmentId String
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
attachment TreatmentDetailAttachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade)
@@id([labCaseId, attachmentId])
@@index([attachmentId])
@@map("lab_case_attachments")
}
model LabCaseDetail {
labCaseId String
treatmentDetailId String @unique

View File

@@ -14,8 +14,12 @@ const prisma = new PrismaClient();
async function main() {
const counts = {
labCaseTaskStatusEvents: await prisma.labCaseTaskStatusEvent.count(),
labCaseComments: await prisma.labCaseComment.count(),
labCaseAttachments: await prisma.labCaseAttachment.count(),
labCaseTasks: await prisma.labCaseTask.count(),
labCaseSends: await prisma.labCaseSend.count(),
labCaseToothProsthesis: await prisma.labCaseToothProsthesis.count(),
labCaseDetails: await prisma.labCaseDetail.count(),
labCases: await prisma.labCase.count(),
attachments: await prisma.treatmentDetailAttachment.count(),
@@ -27,8 +31,12 @@ async function main() {
console.log('Current row counts:', counts);
await prisma.$transaction([
prisma.labCaseTaskStatusEvent.deleteMany(),
prisma.labCaseComment.deleteMany(),
prisma.labCaseAttachment.deleteMany(),
prisma.labCaseTask.deleteMany(),
prisma.labCaseSend.deleteMany(),
prisma.labCaseToothProsthesis.deleteMany(),
prisma.labCaseDetail.deleteMany(),
prisma.labCase.deleteMany(),
prisma.treatmentDetailAttachment.deleteMany(),
@@ -37,7 +45,9 @@ async function main() {
prisma.appointment.deleteMany(),
]);
console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.');
console.log(
'✅ Cleared appointments, treatments, lab cases, tasks, comments, attachments, and related rows.',
);
}
main()

View File

@@ -6,8 +6,10 @@ import {
Patch,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -42,6 +44,26 @@ export class CasesController {
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Get(':id/attachments/:attachmentId/file')
@ApiOperation({ summary: 'Download an attachment shared with this lab case' })
async downloadAttachment(
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
@Req() req,
@Res() res: Response,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
const file = await this.casesService.streamCaseAttachment(
id,
attachmentId,
organizationId,
req.user.id,
);
res.setHeader('Content-Type', file.mimeType);
res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`);
file.stream.pipe(res);
}
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Toggle task important flag' })
updateTask(

View File

@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
@@ -54,6 +55,20 @@ const labCaseListInclude = {
},
},
},
toothProsthesis: true,
attachments: {
include: {
attachment: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
},
},
},
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
@@ -283,6 +298,43 @@ export class CasesService {
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async streamCaseAttachment(
labCaseId: string,
attachmentId: string,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const link = await this.prisma.labCaseAttachment.findFirst({
where: {
labCaseId,
attachmentId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
include: {
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
},
});
if (!link?.attachment) {
throw new NotFoundException('Attachment not found');
}
if (!existsSync(link.attachment.storagePath)) {
throw new NotFoundException('Attachment file is missing on disk');
}
return {
stream: createReadStream(link.attachment.storagePath),
fileName: link.attachment.fileName,
mimeType: link.attachment.mimeType,
};
}
async updateTask(
labCaseId: string,
taskId: string,
@@ -457,6 +509,18 @@ export class CasesService {
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
})),
toothProsthesis: lc.toothProsthesis.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
})),
attachments: lc.attachments.map((row) => ({
id: row.attachment.id,
fileName: row.attachment.fileName,
mimeType: row.attachment.mimeType,
sizeBytes: row.attachment.sizeBytes,
createdAt: row.attachment.createdAt.toISOString(),
})),
sends: lc.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization.name,

View File

@@ -81,6 +81,11 @@ export class SaveLabCaseDto {
@ValidateNested({ each: true })
@Type(() => LabCaseToothProsthesisDto)
toothProsthesis?: LabCaseToothProsthesisDto[];
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })
attachmentIds?: string[];
}
export class SaveTreatmentLabCasesDto {

View File

@@ -56,6 +56,13 @@ const treatmentInclude = {
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
attachments: {
include: {
attachment: {
select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true },
},
},
},
},
},
};
@@ -427,6 +434,29 @@ export class TreatmentsService {
})),
});
}
await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } });
const attachmentIds = lc.attachmentIds ?? [];
if (attachmentIds.length > 0) {
const validAttachments = await tx.treatmentDetailAttachment.findMany({
where: {
id: { in: attachmentIds },
detailId: { in: lc.treatmentDetailIds },
},
select: { id: true },
});
if (validAttachments.length !== attachmentIds.length) {
throw new BadRequestException(
'One or more attachments are invalid for this lab case',
);
}
await tx.labCaseAttachment.createMany({
data: attachmentIds.map((attachmentId) => ({
labCaseId: row.id,
attachmentId,
})),
});
}
}
return tx.treatment.findUniqueOrThrow({
@@ -766,6 +796,15 @@ export class TreatmentsService {
tooth: string;
prosthesisTypeCode: string;
}>;
attachments?: Array<{
attachment: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
};
}>;
}) {
return {
id: lc.id,
@@ -784,6 +823,13 @@ export class TreatmentsService {
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachments: (lc.attachments ?? []).map((row) => ({
id: row.attachment.id,
fileName: row.attachment.fileName,
mimeType: row.attachment.mimeType,
sizeBytes: row.attachment.sizeBytes,
createdAt: row.attachment.createdAt.toISOString(),
})),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,

View File

@@ -353,6 +353,7 @@
"patientMobile": "Mobile",
"showComments": "Comments",
"commentsCount": "Comments ({count})",
"latestAttachment": "Latest file",
"prevPage": "Previous",
"nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to multi-select. Applies to the active detail.",
"toothChartWholePlan": "Show whole treatment plan",
"labShipmentAttachments": "Files for the lab",
"labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.",
"selectedLabel": "Selected:",
"selectedEmpty": "—",
"upperArch": "Upper arch",

View File

@@ -353,6 +353,7 @@
"patientMobile": "موبایل",
"showComments": "نظرات",
"commentsCount": "نظرات ({count})",
"latestAttachment": "آخرین فایل",
"prevPage": "قبلی",
"nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.",
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.",
"toothChartWholePlan": "نمایش کل طرح درمان",
"labShipmentAttachments": "فایل‌ها برای لابراتوار",
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.",
"selectedLabel": "انتخاب شده:",
"selectedEmpty": "—",
"upperArch": "قوس بالا",

View File

@@ -353,6 +353,7 @@
"patientMobile": "Mobiel",
"showComments": "Opmerkingen",
"commentsCount": "Opmerkingen ({count})",
"latestAttachment": "Laatste bestand",
"prevPage": "Vorige",
"nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
@@ -583,7 +584,11 @@
"noActiveOrgs": "Geen actieve gekoppelde organisaties.",
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
"toothChartWholePlan": "Hele behandelplan tonen",
"labShipmentAttachments": "Bestanden voor het lab",
"labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.",
"selectedLabel": "Geselecteerd:",
"selectedEmpty": "—",
"upperArch": "Bovenboog",

View File

@@ -11,6 +11,8 @@ import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
@@ -210,6 +212,39 @@ export default function CasesPage() {
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
const loadCaseAttachmentBlob = useCallback(
(caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId),
[],
);
const latestCaseAttachment = useMemo(() => {
if (!selectedCase?.attachments.length) return null;
return [...selectedCase.attachments].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [selectedCase?.attachments]);
const caseProsthesisRows = useMemo(() => {
if (!selectedCase) return [];
if (selectedCase.toothProsthesis.length > 0) {
const byCode = new Map<string, string[]>();
for (const row of selectedCase.toothProsthesis) {
const key = row.prosthesisTypeCode;
const teeth = byCode.get(key) ?? [];
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
byCode.set(key, teeth);
}
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth,
}));
}
return selectedCase.tasksByTooth.map((g) => ({
prosthesisTypeCode: g.prosthesisTypeCode,
teeth: g.teeth,
}));
}, [selectedCase]);
function clearFilters() {
setSearch('');
setClinicId('');
@@ -446,6 +481,25 @@ export default function CasesPage() {
</div>
</header>
<div className="flex flex-wrap items-start gap-4">
<CaseToothChartPanel
details={selectedCase.details}
prosthesisRows={caseProsthesisRows}
scale={0.5}
className="min-w-0 flex-1"
/>
{latestCaseAttachment && selectedCaseId ? (
<div className="shrink-0 space-y-1">
<p className="text-xs font-medium text-text-secondary">{t('latestAttachment')}</p>
<LabCaseAttachmentPreview
caseId={selectedCaseId}
attachment={latestCaseAttachment}
loadBlob={loadCaseAttachmentBlob}
/>
</div>
) : null}
</div>
{selectedCase.details.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
@@ -476,12 +530,13 @@ export default function CasesPage() {
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
<Badge
truncate
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
>
{group.prosthesisTypeLabel}
</span>
</Badge>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth),

View File

@@ -10,7 +10,7 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
labTaskStatusSelectClass,
labTaskStatusSelectStyle,
labTaskStatusVariant,
} from '@/components/ui/lab/labTaskStatusDisplay';
import {
@@ -321,7 +321,8 @@ export default function TasksPage() {
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px] font-medium`}
style={labTaskStatusSelectStyle(task.status)}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -354,12 +355,15 @@ export default function TasksPage() {
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
<Badge
fixedWidth={false}
truncate
title={task.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
className="w-[7rem]"
>
{task.prosthesisTypeLabel}
</span>
</Badge>
</div>
</div>

View File

@@ -0,0 +1,63 @@
'use client';
import { useMemo } from 'react';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
import type { FdiToothId } from '@/types/treatment';
export interface CaseToothChartDetail {
teeth: string[];
}
export interface CaseToothChartProsthesisRow {
teeth: string[];
prosthesisTypeCode: string;
}
interface CaseToothChartPanelProps {
details: CaseToothChartDetail[];
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
prosthesisRows: CaseToothChartProsthesisRow[];
scale?: number;
className?: string;
}
/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */
export function CaseToothChartPanel({
details,
prosthesisRows,
scale = 0.5,
className = '',
}: CaseToothChartPanelProps) {
const selected = useMemo(() => {
const set = new Set<FdiToothId>();
for (const detail of details) {
for (const tooth of detail.teeth) set.add(tooth as FdiToothId);
}
return set;
}, [details]);
const toothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
prosthesisRows.forEach((row, index) => {
const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
for (const tooth of row.teeth) {
colors[tooth as FdiToothId] = color;
}
});
return colors;
}, [prosthesisRows]);
if (selected.size === 0) return null;
return (
<FdiToothChart
selected={selected}
readOnly
scale={scale}
toothColors={toothColors}
compact
className={className}
/>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import type { LabCaseAttachmentMeta } from '@/types/cases';
interface LabCaseAttachmentPreviewProps {
caseId: string;
attachment: LabCaseAttachmentMeta;
loadBlob: (caseId: string, attachmentId: string) => Promise<Blob>;
className?: string;
}
export function LabCaseAttachmentPreview({
caseId,
attachment,
loadBlob,
className = 'aspect-square w-full max-w-[11rem]',
}: LabCaseAttachmentPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await loadBlob(caseId, attachment.id);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [caseId, attachment.id, loadBlob]);
const isImage = attachment.mimeType.startsWith('image/');
const isPdf = attachment.mimeType === 'application/pdf';
return (
<div
className={`${className} rounded-[var(--radius-md)] border border-border/60 bg-background-secondary overflow-hidden`}
title={attachment.fileName}
>
{url && isImage ? (
<img src={url} alt={attachment.fileName} className="h-full w-full object-cover" />
) : url && isPdf ? (
<iframe src={url} title={attachment.fileName} className="h-full w-full border-0" />
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 p-2 text-text-muted">
<FileText className="h-8 w-8 shrink-0 icon-flat" aria-hidden />
<span className="line-clamp-2 text-center text-[10px] leading-tight">
{failed ? 'Preview unavailable' : attachment.fileName}
</span>
</div>
)}
</div>
);
}

View File

@@ -1,17 +1,25 @@
import type { CSSProperties } from 'react';
import type { BadgeVariant } from '@/components/ui/shared/Badge';
import type { LabTaskStatus } from '@/types/cases';
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
return status === 'COMPLETED' ? 'success' : 'default';
return status === 'COMPLETED' ? 'success' : 'warning';
}
export function labTaskStatusSelectClass(status: LabTaskStatus): string {
switch (status) {
case 'COMPLETED':
return 'border-success/60 text-success';
case 'IN_PROGRESS':
return 'border-primary/60 text-primary';
default:
return '';
}
/**
* Inline style for the closed status <select> so its text/border reflect the
* current value (yellow = in progress, green = completed). Uses the same badge
* token colors as the badges for consistency. Native <option> colors have
* limited cross-browser support, so only the closed control is themed.
*/
export function labTaskStatusSelectStyle(status: LabTaskStatus): CSSProperties {
const color =
status === 'COMPLETED'
? 'var(--color-badge-success-fg)'
: 'var(--color-badge-warning-fg)';
const borderColor =
status === 'COMPLETED'
? 'var(--color-badge-success-border)'
: 'var(--color-badge-warning-border)';
return { color, borderColor };
}

View File

@@ -14,11 +14,14 @@ import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -186,6 +189,39 @@ export function ConnectionCaseHistoryContent({
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
}
const loadClinicAttachmentBlob = useCallback(
(_caseId: string, attachmentId: string) => treatmentsApi.getAttachmentFileBlob(attachmentId),
[],
);
const latestCaseAttachment = useMemo(() => {
if (!selectedCase?.attachments.length) return null;
return [...selectedCase.attachments].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [selectedCase?.attachments]);
const caseProsthesisRows = useMemo(() => {
if (!selectedCase) return [];
if (selectedCase.toothProsthesis.length > 0) {
const byCode = new Map<string, string[]>();
for (const row of selectedCase.toothProsthesis) {
const key = row.prosthesisTypeCode;
const teeth = byCode.get(key) ?? [];
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
byCode.set(key, teeth);
}
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth,
}));
}
return selectedCase.tasksByTooth.map((g) => ({
prosthesisTypeCode: g.prosthesisTypeCode,
teeth: g.teeth,
}));
}, [selectedCase]);
return (
<div className="space-y-6">
<div>
@@ -348,6 +384,27 @@ export function ConnectionCaseHistoryContent({
</div>
</header>
<div className="flex flex-wrap items-start gap-4">
<CaseToothChartPanel
details={selectedCase.details}
prosthesisRows={caseProsthesisRows}
scale={0.5}
className="min-w-0 flex-1"
/>
{latestCaseAttachment && selectedCaseId ? (
<div className="shrink-0 space-y-1">
<p className="text-xs font-medium text-text-secondary">
{tCases('latestAttachment')}
</p>
<LabCaseAttachmentPreview
caseId={selectedCaseId}
attachment={latestCaseAttachment}
loadBlob={loadClinicAttachmentBlob}
/>
</div>
) : null}
</div>
{selectedCase.details.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-text-primary">
@@ -383,12 +440,13 @@ export function ConnectionCaseHistoryContent({
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
<Badge
truncate
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
>
{group.prosthesisTypeLabel}
</span>
</Badge>
<span className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
teeth: formatToothList(group.teeth),

View File

@@ -11,6 +11,15 @@ interface BadgeProps {
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
/**
* Inline style overriding the variant colors — e.g. dynamic prosthesis-type
* pastels via `prosthesisTypeBadgeStyle(code)`. Wins over variant classes.
*/
style?: React.CSSProperties;
/** Native tooltip, useful when the label may be truncated. */
title?: string;
/** Clip an over-long label with an ellipsis instead of wrapping/overflowing. */
truncate?: boolean;
}
const variantStyles: Record<BadgeVariant, string> = {
@@ -29,16 +38,22 @@ export function Badge({
variant = 'default',
className,
fixedWidth = true,
style,
title,
truncate = false,
}: BadgeProps) {
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
const wrapClass = truncate ? '' : 'whitespace-nowrap';
return (
<span
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none ${wrapClass} ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
style={style}
title={title}
>
{children}
{truncate ? <span className="w-full truncate text-center">{children}</span> : children}
</span>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useId } from 'react';
import { useId, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
@@ -21,14 +21,37 @@ function quadrantMirrored(fdi: FdiToothId): boolean {
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
onToggle: (fdi: FdiToothId) => void;
onToggle?: (fdi: FdiToothId) => void;
disabled?: boolean;
/** Non-interactive display (Cases / connection history). */
readOnly?: boolean;
/** Visual scale via CSS zoom (0.5 = half size). */
scale?: number;
/** Per-tooth accent color for selected glow (prosthesis / treatment-type palettes). */
toothColors?: Partial<Record<FdiToothId, string>>;
/** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
headerControl?: ReactNode;
/** Compact card for embedded case detail panels. */
compact?: boolean;
className?: string;
}
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
export function FdiToothChart({
selected,
onToggle,
disabled,
readOnly = false,
scale = 1,
toothColors,
headerControl,
compact = false,
className = '',
}: FdiToothChartProps) {
const t = useTranslations('treatment');
const uid = useId().replace(/:/g, '');
const archPeak = 10;
const interactive = !readOnly && Boolean(onToggle);
const isDisabled = disabled || readOnly;
const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
'18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 },
@@ -104,6 +127,20 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return normalized * 6;
};
const toothAccent = (fdi: FdiToothId) => toothColors?.[fdi];
const numberColorClass = (fdi: FdiToothId, isSel: boolean) => {
if (!isSel) return 'text-text-muted';
const accent = toothAccent(fdi);
return accent ? '' : 'text-primary';
};
const numberStyle = (fdi: FdiToothId, isSel: boolean): CSSProperties | undefined => {
if (!isSel) return undefined;
const accent = toothAccent(fdi);
return accent ? { color: accent } : undefined;
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => {
@@ -115,26 +152,9 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
<button
type="button"
disabled={disabled}
onClick={() => onToggle(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
const accent = toothAccent(fdi);
const glyph = (
<ToothGlyph
fdi={fdi}
kind={kind}
@@ -144,8 +164,42 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
accentColor={isSel ? accent : undefined}
/>
);
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onClick={() => onToggle?.(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
)}
</div>
</div>
);
@@ -153,20 +207,12 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
</div>
);
return (
<div className="surface-card p-3 space-y-3">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('toothChartTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">
{t('toothChartHint')}
</p>
</div>
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
{t('selectedLabel')} {selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
const cardClass = compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const chartBody = (
<>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
<div className="overflow-x-auto py-1 -mx-1 px-1">
@@ -184,9 +230,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${numberColorClass(fdi, isSel)}`}
style={numberStyle(fdi, isSel)}
>
{fdi}
</span>
@@ -204,9 +249,8 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${numberColorClass(fdi, isSel)}`}
style={numberStyle(fdi, isSel)}
>
{fdi}
</span>
@@ -222,6 +266,36 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
</>
);
return (
<div className={`${cardClass} ${className}`}>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
</h3>
{!compact && (
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
)}
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
{headerControl}
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
</div>
{scale !== 1 ? (
<div style={{ zoom: scale }} className="origin-top-left w-fit">
{chartBody}
</div>
) : (
chartBody
)}
</div>
);
}

View File

@@ -274,6 +274,16 @@ export function LabCasesDispatchPanel({
);
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
if (!activeLabCase || sent) return;
const set = new Set(activeLabCase.attachmentIds);
if (checked) set.add(attachmentId);
else set.delete(attachmentId);
updateActiveLabCase({ attachmentIds: [...set] });
}
const activeDetailAttachments = activeDetail?.attachmentMetas ?? [];
const includedInActiveShipment = activeLabCase
? [activeDetail]
: [];
@@ -393,6 +403,26 @@ export function LabCasesDispatchPanel({
)}
</div>
{!sent && activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{activeLabCase.id ? (
<LabCaseCommentsPanel
caseId={activeLabCase.id}

View File

@@ -14,6 +14,24 @@ interface ToothGlyphProps {
mirrored?: boolean;
upsideDown?: boolean;
className?: string;
/** When set, selected tooth glow + fill use this color instead of primary. */
accentColor?: string;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.replace('#', '');
if (normalized.length !== 6) return null;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
return { r, g, b };
}
function rgbaFromHex(hex: string, alpha: number): string {
const rgb = hexToRgb(hex);
if (!rgb) return `rgba(9, 169, 188, ${alpha})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function filledSilhouette(
@@ -39,8 +57,13 @@ function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): Rea
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
}
function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode {
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
function renderClinicalFill(
model: ToothPathModel,
selected: boolean,
gradientId: string,
accentColor?: string,
): ReactNode {
const stroke = selected ? (accentColor ?? 'var(--color-primary)') : '#1e293b';
const strokeW = selected ? 2.4 : 1.35;
const fill = `url(#${gradientId})`;
return (
@@ -50,6 +73,7 @@ function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId
</>
);
}
/** FDI chart tooth: clinical gradient + roots only. */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
@@ -60,9 +84,14 @@ export const ToothGlyph = memo(function ToothGlyph({
mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
accentColor,
}: ToothGlyphProps) {
const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
const filter = selected
? accentColor
? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})`
: 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))'
: undefined;
if (!model) {
return (
@@ -74,7 +103,19 @@ export const ToothGlyph = memo(function ToothGlyph({
);
}
const body = renderClinicalFill(model, selected, gradientId);
const body = renderClinicalFill(model, selected, gradientId, accentColor);
const selectedStops = accentColor
? {
inner: '#ffffff',
mid: accentColor,
outer: accentColor,
}
: {
inner: '#cffafe',
mid: '#5eead4',
outer: '#0e7490',
};
return (
<svg
@@ -85,9 +126,9 @@ export const ToothGlyph = memo(function ToothGlyph({
>
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
<stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
<stop offset="0%" stopColor={selected ? selectedStops.inner : '#ffffff'} />
<stop offset="55%" stopColor={selected ? selectedStops.mid : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? selectedStops.outer : '#93c5fd'} />
</radialGradient>
</defs>
<g

View File

@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
@@ -133,6 +133,7 @@ function newLabCaseDraft(): LabCaseDraft {
destinationOrganizationId: null,
detailClientIds: [],
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
@@ -180,6 +181,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
@@ -296,6 +298,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
@@ -388,6 +391,35 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const wholePlanTeethSet = useMemo(() => {
const set = new Set<FdiToothId>();
for (const detail of details) {
for (const tooth of detail.teeth) set.add(tooth);
}
return set;
}, [details]);
const wholePlanToothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
for (let i = 0; i < details.length; i++) {
const detail = details[i];
const catalogIndex = treatmentCatalog.findIndex((e) => e.code === detail.treatmentType);
const color = treatmentTypeColor(detail.treatmentType, catalogIndex >= 0 ? catalogIndex : i);
for (const tooth of detail.teeth) {
if (!(tooth in colors)) colors[tooth] = color;
}
}
return colors;
}, [details, treatmentCatalog]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
// Reset whole-plan overview when switching details.
useEffect(() => {
setShowWholeTreatmentPlan(false);
}, [activeDetailId]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
@@ -817,6 +849,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
attachmentIds: lc.attachmentIds,
}));
if (payload.length === 0) {
@@ -1040,9 +1073,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<div className="space-y-3 min-w-0 w-full">
<FdiToothChart
selected={selectedTeethSet}
selected={chartSelectedTeeth}
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
details.length > 1 ? (
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showWholeTreatmentPlan}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
className="rounded border-border"
/>
{t('toothChartWholePlan')}
</label>
) : undefined
}
onToggle={(fdi) => {
if (!canEditTreatmentForDay || isDetailLocked(activeDetail)) return;
if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
setDetails((prev) =>
prev.map((d) => {
if (d.clientId !== activeDetailId) return d;

View File

@@ -33,4 +33,12 @@ export const casesApi = {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
return response.data;
},
getAttachmentFileBlob: async (caseId: string, attachmentId: string): Promise<Blob> => {
const response = await apiClient.get(`/cases/${caseId}/attachments/${attachmentId}/file`, {
responseType: 'blob',
timeout: 120_000,
});
return response.data;
},
};

View File

@@ -66,6 +66,14 @@ export interface LabCaseComment {
showVisibilityStatus?: boolean;
}
export interface LabCaseAttachmentMeta {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface LabCaseDetail {
id: string;
sentAt: string | null;
@@ -84,6 +92,12 @@ export interface LabCaseDetail {
teeth: string[];
comment: string | null;
}>;
toothProsthesis: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
attachments: LabCaseAttachmentMeta[];
sends: Array<{
organizationId: string;
organizationName: string;

View File

@@ -103,6 +103,7 @@ export interface PastLabCase {
prosthesisTypeCode: string;
}>;
sends?: LabCaseSendInfo[];
attachments?: TreatmentAttachmentMeta[];
}
export interface PastTreatment {
@@ -144,6 +145,7 @@ export interface LabCaseDraft {
destinationOrganizationId: string | null;
detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
attachmentIds: string[];
sentAt?: string | null;
sends?: LabCaseSendInfo[];
}
@@ -170,6 +172,7 @@ export interface SaveLabCasePayload {
tooth: string;
prosthesisTypeCode: string;
}>;
attachmentIds?: string[];
}
export interface SaveTreatmentPayload {
@@ -192,4 +195,5 @@ export interface LabCaseResponse {
}>;
sends: LabCaseSendInfo[];
toothProsthesis?: LabCaseToothProsthesisDraft[];
attachments?: TreatmentAttachmentMeta[];
}