diff --git a/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql new file mode 100644 index 0000000..ed71309 --- /dev/null +++ b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql @@ -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; diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts index 1f3bfa5..4098bf0 100644 --- a/backend/prisma/reset-treatment-data.ts +++ b/backend/prisma/reset-treatment-data.ts @@ -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 { diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4494397..3e7c2df 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/prisma/scripts/clear-clinical-test-data.ts b/backend/prisma/scripts/clear-clinical-test-data.ts index 91bf5ff..99cee7c 100644 --- a/backend/prisma/scripts/clear-clinical-test-data.ts +++ b/backend/prisma/scripts/clear-clinical-test-data.ts @@ -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() diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 00ecae5..8b319d6 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -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( diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 5404942..d7fe45c 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -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, diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 0e38e8d..c0e7a64 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -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 { diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 1964e1c..0fed982 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -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, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5afd92b..532a3e0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e67a52f..d8e53b3 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "قوس بالا", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 9aaf99e..786520f 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index ad13b3f..c274887 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -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(); + 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() { +
+ + {latestCaseAttachment && selectedCaseId ? ( +
+

{t('latestAttachment')}

+ +
+ ) : null} +
+ {selectedCase.details.length > 0 && (

{t('treatmentDetails')}

@@ -476,12 +530,13 @@ export default function CasesPage() { className="rounded-md border border-border p-3 space-y-2" >
- {group.prosthesisTypeLabel} - + {t('toothGroupTitle', { teeth: formatToothList(group.teeth), diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 8b2837e..64e9d36 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -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) => (
diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx new file mode 100644 index 0000000..b9fd80d --- /dev/null +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -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(); + 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> = {}; + 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 ( + + ); +} diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx new file mode 100644 index 0000000..00a2f82 --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx @@ -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; + className?: string; +} + +export function LabCaseAttachmentPreview({ + caseId, + attachment, + loadBlob, + className = 'aspect-square w-full max-w-[11rem]', +}: LabCaseAttachmentPreviewProps) { + const [url, setUrl] = useState(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 ( +
+ {url && isImage ? ( + {attachment.fileName} + ) : url && isPdf ? ( +