feature/cases #55
@@ -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;
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "قوس بالا",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
63
frontend/src/components/ui/lab/CaseToothChartPanel.tsx
Normal file
63
frontend/src/components/ui/lab/CaseToothChartPanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
Normal file
67
frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,37 +152,54 @@ 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';
|
||||
const accent = toothAccent(fdi);
|
||||
|
||||
const glyph = (
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
kind={kind}
|
||||
gradientId={gid}
|
||||
selected={isSel}
|
||||
upper={upper}
|
||||
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`}>
|
||||
<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 })
|
||||
}
|
||||
>
|
||||
<ToothGlyph
|
||||
fdi={fdi}
|
||||
kind={kind}
|
||||
gradientId={gid}
|
||||
selected={isSel}
|
||||
upper={upper}
|
||||
mirrored={quadrantMirrored(fdi)}
|
||||
upsideDown={upper}
|
||||
className={tweak?.glyph ?? size.glyph}
|
||||
/>
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user