improvement: all demo bugs fixed. give me more baby.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
-- Move the "important" flag from individual tasks to the case as a whole.
|
||||
ALTER TABLE "lab_cases" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Carry over existing importance: a case is important if any of its tasks were.
|
||||
UPDATE "lab_cases" lc
|
||||
SET "isImportant" = true
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "lab_case_tasks" t
|
||||
WHERE t."labCaseId" = lc."id" AND t."isImportant" = true
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_isImportant_idx";
|
||||
ALTER TABLE "lab_case_tasks" DROP COLUMN "isImportant";
|
||||
@@ -195,6 +195,7 @@ model LabCase {
|
||||
sortOrder Int
|
||||
destinationOrganizationId String?
|
||||
sentAt DateTime?
|
||||
isImportant Boolean @default(false)
|
||||
|
||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||
details LabCaseDetail[]
|
||||
@@ -333,7 +334,6 @@ model LabCaseTask {
|
||||
workflowStepCode String
|
||||
stepOrder Int
|
||||
stepLabel String
|
||||
isImportant Boolean @default(false)
|
||||
status LabTaskStatus @default(IN_PROGRESS)
|
||||
lastStatusChangedByUserId String?
|
||||
lastStatusChangedAt DateTime?
|
||||
@@ -348,7 +348,6 @@ model LabCaseTask {
|
||||
|
||||
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
||||
@@index([labCaseId, status])
|
||||
@@index([labCaseId, isImportant])
|
||||
@@map("lab_case_tasks")
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CasesService } from './cases.service';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
|
||||
|
||||
@ApiTags('cases')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -64,15 +64,14 @@ export class CasesController {
|
||||
file.stream.pipe(res);
|
||||
}
|
||||
|
||||
@Patch(':id/tasks/:taskId')
|
||||
@ApiOperation({ summary: 'Toggle task important flag' })
|
||||
updateTask(
|
||||
@Patch(':id/important')
|
||||
@ApiOperation({ summary: 'Toggle the important flag for a whole case' })
|
||||
setCaseImportant(
|
||||
@Param('id') id: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateLabCaseTaskDto,
|
||||
@Body() dto: UpdateLabCaseImportantDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
|
||||
return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
|
||||
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
|
||||
const labCaseListInclude = {
|
||||
@@ -335,51 +335,39 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
async setCaseImportant(
|
||||
labCaseId: string,
|
||||
taskId: string,
|
||||
dto: UpdateLabCaseTaskDto,
|
||||
dto: UpdateLabCaseImportantDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
labCaseId,
|
||||
labCase: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
await this.prisma.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: { isImportant: dto.isImportant },
|
||||
include: {
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
statusEvents: {
|
||||
orderBy: { changedAt: 'asc' },
|
||||
include: { changedBy: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
[updated.prosthesisTypeCode],
|
||||
locale,
|
||||
);
|
||||
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
|
||||
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
||||
}
|
||||
|
||||
private buildListWhere(
|
||||
@@ -499,6 +487,7 @@ export class CasesService {
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
isImportant: lc.isImportant,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
@@ -585,7 +574,6 @@ export class CasesService {
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
isImportant: task.isImportant,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||
lastStatusChangedBy: task.lastStatusChangedBy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdateLabCaseTaskDto {
|
||||
export class UpdateLabCaseImportantDto {
|
||||
@IsBoolean()
|
||||
isImportant: boolean;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,14 @@ export class UpdateLabTaskDto {
|
||||
status: LabTaskStatus;
|
||||
}
|
||||
|
||||
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
|
||||
export type TaskSortField =
|
||||
| 'date'
|
||||
| 'status'
|
||||
| 'clinic'
|
||||
| 'patient'
|
||||
| 'important'
|
||||
| 'prosthesis'
|
||||
| 'taskType';
|
||||
|
||||
export class ListLabTasksDto {
|
||||
@IsOptional()
|
||||
@@ -59,7 +66,7 @@ export class ListLabTasksDto {
|
||||
sentTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important'])
|
||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||
sortBy?: TaskSortField;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -188,9 +188,9 @@ export class TasksService {
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||
},
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,15 +229,24 @@ export class TasksService {
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'important':
|
||||
return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'prosthesis':
|
||||
return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'taskType':
|
||||
return [
|
||||
{ workflowStepCode: dir },
|
||||
{ stepOrder: 'asc' },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'date':
|
||||
default:
|
||||
// date / caseId / taskId / stepId — newest first by default.
|
||||
return [
|
||||
{ labCase: { sentAt: dir } },
|
||||
{ labCaseId: 'asc' },
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
{ labCaseId: dir },
|
||||
{ id: dir },
|
||||
{ stepOrder: dir },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -259,7 +268,7 @@ export class TasksService {
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
isImportant: task.isImportant,
|
||||
isImportant: task.labCase.isImportant,
|
||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||
lastStatusChangedBy: task.lastStatusChangedBy
|
||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"statusInProgress": "In progress",
|
||||
"statusCompleted": "Completed",
|
||||
"importantLabel": "Important",
|
||||
"markCaseImportant": "Mark case as important",
|
||||
"markImportant": "Mark as important",
|
||||
"lastUpdatedBy": "Updated by {name}",
|
||||
"lastUpdatedUnknown": "Not started yet",
|
||||
@@ -353,7 +354,13 @@
|
||||
"patientMobile": "Mobile",
|
||||
"showComments": "Comments",
|
||||
"commentsCount": "Comments ({count})",
|
||||
"latestAttachment": "Latest file",
|
||||
"viewAttachments": "View all attachments",
|
||||
"attachmentsDialogTitle": "Case attachments",
|
||||
"attachmentsDialogSubtitle": "Preview and download files shared with this case.",
|
||||
"noAttachments": "No attachments were shared with this case.",
|
||||
"downloadAttachment": "Download",
|
||||
"downloadAllAttachments": "Download all",
|
||||
"attachmentPreviewUnavailable": "Preview unavailable",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||
@@ -391,6 +398,8 @@
|
||||
"sortClinic": "Clinic",
|
||||
"sortPatient": "Patient",
|
||||
"sortImportant": "Important",
|
||||
"sortProsthesis": "Prosthesis type",
|
||||
"sortTaskType": "Task type",
|
||||
"sortDirection": "Sort direction",
|
||||
"clearFilters": "Clear filters",
|
||||
"commentsButton": "Comments",
|
||||
@@ -413,7 +422,10 @@
|
||||
"clinicAuthor": "Clinic",
|
||||
"errorLoad": "Failed to load comments.",
|
||||
"errorPost": "Failed to post comment.",
|
||||
"errorToggle": "Failed to update comment visibility."
|
||||
"errorToggle": "Failed to update comment visibility.",
|
||||
"send": "Send comment",
|
||||
"composerVisible": "Visible to clinic",
|
||||
"composerHidden": "Hidden from clinic"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"statusInProgress": "در حال انجام",
|
||||
"statusCompleted": "انجام شده",
|
||||
"importantLabel": "مهم",
|
||||
"markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
|
||||
"markImportant": "علامتگذاری به عنوان مهم",
|
||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||
"lastUpdatedUnknown": "هنوز شروع نشده",
|
||||
@@ -354,6 +355,13 @@
|
||||
"showComments": "نظرات",
|
||||
"commentsCount": "نظرات ({count})",
|
||||
"latestAttachment": "آخرین فایل",
|
||||
"viewAttachments": "مشاهده همه پیوستها",
|
||||
"attachmentsDialogTitle": "پیوستهای پرونده",
|
||||
"attachmentsDialogSubtitle": "پیشنمایش و دانلود فایلهای بهاشتراکگذاشتهشده با این پرونده.",
|
||||
"noAttachments": "هیچ پیوستی با این پرونده بهاشتراک گذاشته نشده است.",
|
||||
"downloadAttachment": "دانلود",
|
||||
"downloadAllAttachments": "دانلود همه",
|
||||
"attachmentPreviewUnavailable": "پیشنمایش در دسترس نیست",
|
||||
"prevPage": "قبلی",
|
||||
"nextPage": "بعدی",
|
||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||
@@ -391,6 +399,8 @@
|
||||
"sortClinic": "کلینیک",
|
||||
"sortPatient": "بیمار",
|
||||
"sortImportant": "مهم",
|
||||
"sortProsthesis": "نوع پروتز",
|
||||
"sortTaskType": "نوع کار",
|
||||
"sortDirection": "جهت مرتبسازی",
|
||||
"clearFilters": "پاک کردن فیلترها",
|
||||
"commentsButton": "نظرات",
|
||||
@@ -413,7 +423,10 @@
|
||||
"clinicAuthor": "کلینیک",
|
||||
"errorLoad": "بارگذاری نظرات ناموفق بود.",
|
||||
"errorPost": "ثبت نظر ناموفق بود.",
|
||||
"errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود."
|
||||
"errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود.",
|
||||
"send": "ارسال نظر",
|
||||
"composerVisible": "قابل مشاهده برای کلینیک",
|
||||
"composerHidden": "پنهان از کلینیک"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "نوبتها",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"statusInProgress": "Bezig",
|
||||
"statusCompleted": "Voltooid",
|
||||
"importantLabel": "Belangrijk",
|
||||
"markCaseImportant": "Zaak als belangrijk markeren",
|
||||
"markImportant": "Markeren als belangrijk",
|
||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||
"lastUpdatedUnknown": "Nog niet gestart",
|
||||
@@ -354,6 +355,13 @@
|
||||
"showComments": "Opmerkingen",
|
||||
"commentsCount": "Opmerkingen ({count})",
|
||||
"latestAttachment": "Laatste bestand",
|
||||
"viewAttachments": "Alle bijlagen bekijken",
|
||||
"attachmentsDialogTitle": "Zaakbijlagen",
|
||||
"attachmentsDialogSubtitle": "Bekijk en download bestanden die met deze zaak zijn gedeeld.",
|
||||
"noAttachments": "Er zijn geen bijlagen met deze zaak gedeeld.",
|
||||
"downloadAttachment": "Downloaden",
|
||||
"downloadAllAttachments": "Alles downloaden",
|
||||
"attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar",
|
||||
"prevPage": "Vorige",
|
||||
"nextPage": "Volgende",
|
||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||
@@ -391,6 +399,8 @@
|
||||
"sortClinic": "Kliniek",
|
||||
"sortPatient": "Patiënt",
|
||||
"sortImportant": "Belangrijk",
|
||||
"sortProsthesis": "Prothesetype",
|
||||
"sortTaskType": "Taaktype",
|
||||
"sortDirection": "Sorteerrichting",
|
||||
"clearFilters": "Filters wissen",
|
||||
"commentsButton": "Opmerkingen",
|
||||
@@ -413,7 +423,10 @@
|
||||
"clinicAuthor": "Kliniek",
|
||||
"errorLoad": "Opmerkingen laden mislukt.",
|
||||
"errorPost": "Opmerking plaatsen mislukt.",
|
||||
"errorToggle": "Zichtbaarheid bijwerken mislukt."
|
||||
"errorToggle": "Zichtbaarheid bijwerken mislukt.",
|
||||
"send": "Opmerking versturen",
|
||||
"composerVisible": "Zichtbaar voor kliniek",
|
||||
"composerHidden": "Verborgen voor kliniek"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Afspraken",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||
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 {
|
||||
formatCaseDateTime,
|
||||
formatPatientName,
|
||||
} from '@/components/ui/lab/caseDetailUtils';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
@@ -29,47 +29,11 @@ import type {
|
||||
LabTaskStatus,
|
||||
PaginatedLabCases,
|
||||
} from '@/types/cases';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null, locale: string) {
|
||||
if (!value) return '—';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>{completed}/{total}</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CasesPage() {
|
||||
const t = useTranslations('cases');
|
||||
const tTreatment = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
const { currentOrganization, user } = useAuth();
|
||||
const toast = useToast();
|
||||
@@ -99,7 +63,7 @@ export default function CasesPage() {
|
||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||
const [commentCount, setCommentCount] = useState(0);
|
||||
|
||||
const canEdit = canEditCases(currentOrganization);
|
||||
@@ -152,18 +116,24 @@ export default function CasesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (caseId: string) => {
|
||||
const loadDetail = async (caseId: string, options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) {
|
||||
setLoadingDetail(true);
|
||||
}
|
||||
toast.setError('');
|
||||
try {
|
||||
const response = await casesApi.getOne(caseId);
|
||||
setSelectedCase(response.data);
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
|
||||
if (!options?.silent) {
|
||||
setSelectedCase(null);
|
||||
}
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -217,34 +187,6 @@ export default function CasesPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
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('');
|
||||
@@ -254,18 +196,22 @@ export default function CasesPage() {
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function handleImportantToggle(taskId: string, isImportant: boolean) {
|
||||
if (!selectedCaseId || !canEdit) return;
|
||||
async function handleCaseImportantToggle(isImportant: boolean) {
|
||||
if (!selectedCaseId || !canEdit || !selectedCase) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
const previousCase = selectedCase;
|
||||
setSelectedCase({ ...selectedCase, isImportant });
|
||||
|
||||
setUpdatingImportant(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
|
||||
await loadDetail(selectedCaseId);
|
||||
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
||||
setSelectedCase(response.data);
|
||||
} catch (error: unknown) {
|
||||
setSelectedCase(previousCase);
|
||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingTaskId(null);
|
||||
setUpdatingImportant(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,13 +337,13 @@ export default function CasesPage() {
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{formatDateTime(item.sentAt, locale)}
|
||||
{formatCaseDateTime(item.sentAt, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<TaskProgressBar
|
||||
<CaseTaskProgressBar
|
||||
completed={item.taskProgress.completed}
|
||||
total={item.taskProgress.total}
|
||||
/>
|
||||
@@ -445,154 +391,25 @@ export default function CasesPage() {
|
||||
) : loadingDetail || !selectedCase ? (
|
||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1 border-b border-border pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{formatPatientName(selectedCase.patient)}
|
||||
</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||
{commentCount > 0
|
||||
? t('commentsCount', { count: commentCount })
|
||||
: t('showComments')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('patientMobile')}: {selectedCase.patient.mobile}
|
||||
</p>
|
||||
<CaseDetailPanel
|
||||
labCase={selectedCase}
|
||||
locale={locale}
|
||||
treatmentLabel={treatmentLabel}
|
||||
statusOptions={statusOptions}
|
||||
loadAttachmentBlob={loadCaseAttachmentBlob}
|
||||
showCommentsButton
|
||||
commentCount={commentCount}
|
||||
onCommentsClick={scrollToComments}
|
||||
canEditImportant={canEdit}
|
||||
updatingImportant={updatingImportant}
|
||||
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||
headerMetaLines={
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('fromClinic', { name: selectedCase.clinic.name })}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
||||
</p>
|
||||
<div className="pt-1 max-w-xs">
|
||||
<p className="text-sm text-text-muted mb-1">
|
||||
{t('taskProgressLabel', {
|
||||
completed: selectedCase.taskProgress.completed,
|
||||
total: selectedCase.taskProgress.total,
|
||||
})}
|
||||
</p>
|
||||
<TaskProgressBar
|
||||
completed={selectedCase.taskProgress.completed}
|
||||
total={selectedCase.taskProgress.total}
|
||||
/>
|
||||
</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>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{selectedCase.details.map((detail) => (
|
||||
<li key={detail.id} className="rounded-md bg-background border border-border p-2">
|
||||
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{t('teethLabel')}: {detail.teeth.join(', ') || '—'}
|
||||
</div>
|
||||
{detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{detail.comment}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('tasksByTooth')}</h3>
|
||||
{selectedCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||
) : (
|
||||
selectedCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
truncate
|
||||
title={group.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{t('toothGroupTitle', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="rounded bg-background p-2 text-sm space-y-1"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
{canEdit ? (
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.isImportant}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleImportantToggle(task.id, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t('importantLabel')}
|
||||
</label>
|
||||
) : task.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{task.lastStatusChangedBy
|
||||
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||
: t('lastUpdatedUnknown')}
|
||||
{task.lastStatusChangedAt
|
||||
? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCaseId ? (
|
||||
commentsSection={
|
||||
selectedCaseId ? (
|
||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||
<LabCaseCommentsPanel
|
||||
caseId={selectedCaseId}
|
||||
@@ -618,8 +435,9 @@ export default function CasesPage() {
|
||||
onError={toast.showError}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -51,13 +51,11 @@ export default function TasksPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
|
||||
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [clinicId, setClinicId] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [importantOnly, setImportantOnly] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
||||
const [sentFrom, setSentFrom] = useState('');
|
||||
const [sentTo, setSentTo] = useState('');
|
||||
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
||||
@@ -87,18 +85,11 @@ export default function TasksPage() {
|
||||
};
|
||||
if (search.trim()) params.q = search.trim();
|
||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||
if (statusFilter) {
|
||||
params.status = statusFilter;
|
||||
} else if (showCompleted) {
|
||||
params.completed = undefined;
|
||||
} else {
|
||||
params.completed = false;
|
||||
}
|
||||
if (importantOnly) params.important = true;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (sentFrom) params.sentFrom = sentFrom;
|
||||
if (sentTo) params.sentTo = sentTo;
|
||||
return params;
|
||||
}, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
|
||||
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
|
||||
|
||||
const clinicOptions = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -228,10 +219,10 @@ export default function TasksPage() {
|
||||
className={`${filterSelectClass} min-w-0 flex-1`}
|
||||
>
|
||||
<option value="date">{t('sortDate')}</option>
|
||||
<option value="status">{t('sortStatus')}</option>
|
||||
<option value="clinic">{t('sortClinic')}</option>
|
||||
<option value="patient">{t('sortPatient')}</option>
|
||||
<option value="important">{t('sortImportant')}</option>
|
||||
<option value="prosthesis">{t('sortProsthesis')}</option>
|
||||
<option value="taskType">{t('sortTaskType')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortDir}
|
||||
@@ -245,30 +236,6 @@ export default function TasksPage() {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{t('showCompleted')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importantOnly}
|
||||
onChange={(e) => {
|
||||
setImportantOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{t('importantOnly')}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-card min-h-[280px]">
|
||||
@@ -279,7 +246,7 @@ export default function TasksPage() {
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task, index) => {
|
||||
const commentsOpen = expandedCommentsCaseId === task.labCaseId;
|
||||
const commentsOpen = expandedCommentsTaskId === task.id;
|
||||
|
||||
return (
|
||||
<li key={task.id}>
|
||||
@@ -343,7 +310,7 @@ export default function TasksPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
|
||||
setExpandedCommentsTaskId(commentsOpen ? null : task.id)
|
||||
}
|
||||
className={`p-1.5 rounded border ${
|
||||
commentsOpen
|
||||
|
||||
245
frontend/src/components/ui/lab/CaseDetailPanel.tsx
Normal file
245
frontend/src/components/ui/lab/CaseDetailPanel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
||||
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
|
||||
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import {
|
||||
buildCaseProsthesisRows,
|
||||
formatCaseDateTime,
|
||||
formatPatientName,
|
||||
latestCaseAttachment,
|
||||
} from '@/components/ui/lab/caseDetailUtils';
|
||||
import type { LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
||||
|
||||
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CaseDetailPanelProps {
|
||||
labCase: LabCaseDetail;
|
||||
locale: string;
|
||||
treatmentLabel: (type: string) => string;
|
||||
statusOptions: { value: LabTaskStatus; label: string }[];
|
||||
loadAttachmentBlob: (caseId: string, attachmentId: string) => Promise<Blob>;
|
||||
/** Extra lines below patient mobile (e.g. connection-specific clinic/lab line). */
|
||||
headerMetaLines?: ReactNode;
|
||||
showCommentsButton?: boolean;
|
||||
commentCount?: number;
|
||||
onCommentsClick?: () => void;
|
||||
canEditImportant?: boolean;
|
||||
updatingImportant?: boolean;
|
||||
onImportantChange?: (checked: boolean) => void;
|
||||
commentsSection?: ReactNode;
|
||||
}
|
||||
|
||||
export function CaseDetailPanel({
|
||||
labCase,
|
||||
locale,
|
||||
treatmentLabel,
|
||||
statusOptions,
|
||||
loadAttachmentBlob,
|
||||
headerMetaLines,
|
||||
showCommentsButton = false,
|
||||
commentCount = 0,
|
||||
onCommentsClick,
|
||||
canEditImportant = false,
|
||||
updatingImportant = false,
|
||||
onImportantChange,
|
||||
commentsSection,
|
||||
}: CaseDetailPanelProps) {
|
||||
const t = useTranslations('cases');
|
||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||
|
||||
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
||||
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{formatPatientName(labCase.patient)}
|
||||
</h2>
|
||||
{!canEditImportant && labCase.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false} className="mt-1">
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
) : null}
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('patientMobile')}: {labCase.patient.mobile}
|
||||
</p>
|
||||
{headerMetaLines}
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('sentAt', { date: formatCaseDateTime(labCase.sentAt, locale) })}
|
||||
</p>
|
||||
<div className="pt-1 max-w-xs">
|
||||
<p className="text-sm text-text-muted mb-1">
|
||||
{t('taskProgressLabel', {
|
||||
completed: labCase.taskProgress.completed,
|
||||
total: labCase.taskProgress.total,
|
||||
})}
|
||||
</p>
|
||||
<CaseTaskProgressBar
|
||||
completed={labCase.taskProgress.completed}
|
||||
total={labCase.taskProgress.total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{showCommentsButton && onCommentsClick ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCommentsClick}>
|
||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||
{commentCount > 0
|
||||
? t('commentsCount', { count: commentCount })
|
||||
: t('showComments')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEditImportant ? (
|
||||
<Checkbox
|
||||
checked={labCase.isImportant ?? false}
|
||||
disabled={updatingImportant}
|
||||
label={t('markCaseImportant')}
|
||||
className="shrink-0"
|
||||
onChange={(checked) => onImportantChange?.(checked)}
|
||||
/>
|
||||
) : null}
|
||||
{previewAttachment && labCase.attachments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachmentsDialogOpen(true)}
|
||||
className="aspect-square w-32 cursor-pointer rounded-[var(--radius-md)] border border-border/60 overflow-hidden transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={previewAttachment.fileName}
|
||||
aria-label={t('viewAttachments')}
|
||||
>
|
||||
<LabCaseAttachmentPreview
|
||||
caseId={labCase.id}
|
||||
attachment={previewAttachment}
|
||||
loadBlob={loadAttachmentBlob}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CaseToothChartPanel
|
||||
details={labCase.details}
|
||||
prosthesisRows={prosthesisRows}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{labCase.details.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{labCase.details.map((detail) => (
|
||||
<li key={detail.id} className="rounded-md bg-background border border-border p-2">
|
||||
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{t('teethLabel')}: {detail.teeth.join(', ') || '—'}
|
||||
</div>
|
||||
{detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{detail.comment}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('tasksByTooth')}</h3>
|
||||
{labCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||
) : (
|
||||
labCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
truncate
|
||||
title={group.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{t('toothGroupTitle', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li key={task.id} className="rounded bg-background p-2 text-sm space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{task.lastStatusChangedBy
|
||||
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||
: t('lastUpdatedUnknown')}
|
||||
{task.lastStatusChangedAt
|
||||
? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{commentsSection}
|
||||
|
||||
<LabCaseAttachmentsDialog
|
||||
open={attachmentsDialogOpen}
|
||||
onClose={() => setAttachmentsDialogOpen(false)}
|
||||
caseId={labCase.id}
|
||||
attachments={labCase.attachments}
|
||||
loadBlob={loadAttachmentBlob}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CaseTaskProgressBar };
|
||||
@@ -19,6 +19,7 @@ interface CaseToothChartPanelProps {
|
||||
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||
scale?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,8 @@ interface CaseToothChartPanelProps {
|
||||
export function CaseToothChartPanel({
|
||||
details,
|
||||
prosthesisRows,
|
||||
scale = 0.5,
|
||||
scale = 1,
|
||||
compact = true,
|
||||
className = '',
|
||||
}: CaseToothChartPanelProps) {
|
||||
const selected = useMemo(() => {
|
||||
@@ -56,7 +58,7 @@ export function CaseToothChartPanel({
|
||||
readOnly
|
||||
scale={scale}
|
||||
toothColors={toothColors}
|
||||
compact
|
||||
compact={compact}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export function LabCaseAttachmentPreview({
|
||||
caseId,
|
||||
attachment,
|
||||
loadBlob,
|
||||
className = 'aspect-square w-full max-w-[11rem]',
|
||||
className = 'h-full w-full',
|
||||
}: LabCaseAttachmentPreviewProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
211
frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx
Normal file
211
frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Download, FileText } from 'lucide-react';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import type { LabCaseAttachmentMeta } from '@/types/cases';
|
||||
|
||||
interface LabCaseAttachmentsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
caseId: string;
|
||||
attachments: LabCaseAttachmentMeta[];
|
||||
loadBlob: (caseId: string, attachmentId: string) => Promise<Blob>;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function AttachmentPreviewTile({
|
||||
caseId,
|
||||
attachment,
|
||||
loadBlob,
|
||||
onDownload,
|
||||
}: {
|
||||
caseId: string;
|
||||
attachment: LabCaseAttachmentMeta;
|
||||
loadBlob: (caseId: string, attachmentId: string) => Promise<Blob>;
|
||||
onDownload: (blob: Blob, fileName: string) => void;
|
||||
}) {
|
||||
const t = useTranslations('cases');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [blob, setBlob] = useState<Blob | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadBlob(caseId, attachment.id);
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(loaded);
|
||||
setBlob(loaded);
|
||||
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 (
|
||||
<article className="rounded-md border border-border bg-background p-3 space-y-3">
|
||||
<div className="aspect-[4/3] w-full overflow-hidden rounded-md border border-border/60 bg-background-secondary">
|
||||
{url && isImage ? (
|
||||
<img src={url} alt={attachment.fileName} className="h-full w-full object-contain" />
|
||||
) : 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-2 p-4 text-text-muted">
|
||||
<FileText className="h-10 w-10 shrink-0 icon-flat" aria-hidden />
|
||||
<span className="line-clamp-2 text-center text-xs">
|
||||
{failed ? t('attachmentPreviewUnavailable') : attachment.fileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate" title={attachment.fileName}>
|
||||
{attachment.fileName}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{formatFileSize(attachment.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!blob || downloading}
|
||||
onClick={() => {
|
||||
if (!blob) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
onDownload(blob, attachment.fileName);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 me-1.5" />
|
||||
{t('downloadAttachment')}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabCaseAttachmentsDialog({
|
||||
open,
|
||||
onClose,
|
||||
caseId,
|
||||
attachments,
|
||||
loadBlob,
|
||||
}: LabCaseAttachmentsDialogProps) {
|
||||
const t = useTranslations('cases');
|
||||
|
||||
const sortedAttachments = [...attachments].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const handleDownload = useCallback((blob: Blob, fileName: string) => {
|
||||
downloadBlob(blob, fileName);
|
||||
}, []);
|
||||
|
||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (sortedAttachments.length === 0) return;
|
||||
setDownloadingAll(true);
|
||||
try {
|
||||
for (const attachment of sortedAttachments) {
|
||||
const blob = await loadBlob(caseId, attachment.id);
|
||||
downloadBlob(blob, attachment.fileName);
|
||||
}
|
||||
} finally {
|
||||
setDownloadingAll(false);
|
||||
}
|
||||
}, [caseId, loadBlob, sortedAttachments]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="case-attachments-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="case-attachments-title" className="text-lg font-semibold text-text-primary">
|
||||
{t('attachmentsDialogTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t('attachmentsDialogSubtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{sortedAttachments.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={downloadingAll}
|
||||
onClick={() => void handleDownloadAll()}
|
||||
>
|
||||
<Download className="h-4 w-4 me-1.5" />
|
||||
{t('downloadAllAttachments')}
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogCloseButton onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedAttachments.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noAttachments')}</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{sortedAttachments.map((attachment) => (
|
||||
<AttachmentPreviewTile
|
||||
key={attachment.id}
|
||||
caseId={caseId}
|
||||
attachment={attachment}
|
||||
loadBlob={loadBlob}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Eye, EyeOff, Send } from 'lucide-react';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import type { LabCaseComment } from '@/types/cases';
|
||||
|
||||
interface LabCaseCommentsPanelProps {
|
||||
@@ -15,6 +14,13 @@ interface LabCaseCommentsPanelProps {
|
||||
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
|
||||
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
|
||||
onError?: (message: string) => void;
|
||||
/**
|
||||
* Deferred composer: the parent owns the draft value and triggers the post
|
||||
* elsewhere (e.g. the "Send to lab" button). No send icon is shown.
|
||||
*/
|
||||
deferSubmit?: boolean;
|
||||
composerValue?: string;
|
||||
onComposerValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LabCaseCommentsPanel({
|
||||
@@ -25,6 +31,9 @@ export function LabCaseCommentsPanel({
|
||||
onPost,
|
||||
onToggleVisibility,
|
||||
onError,
|
||||
deferSubmit = false,
|
||||
composerValue,
|
||||
onComposerValueChange,
|
||||
}: LabCaseCommentsPanelProps) {
|
||||
const t = useTranslations('caseComments');
|
||||
const [comments, setComments] = useState<LabCaseComment[]>([]);
|
||||
@@ -51,7 +60,7 @@ export function LabCaseCommentsPanel({
|
||||
|
||||
async function handlePost() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || !canPost) return;
|
||||
if (!trimmed || !canPost || posting) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
const created = await onPost(trimmed, visibleToClinic);
|
||||
@@ -65,6 +74,13 @@ export function LabCaseCommentsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handlePost();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(comment: LabCaseComment) {
|
||||
if (!onToggleVisibility || !canToggleVisibility) return;
|
||||
try {
|
||||
@@ -112,12 +128,8 @@ export function LabCaseCommentsPanel({
|
||||
type="button"
|
||||
onClick={() => void handleToggle(comment)}
|
||||
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
|
||||
title={
|
||||
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
|
||||
}
|
||||
aria-label={
|
||||
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
|
||||
}
|
||||
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||
>
|
||||
{comment.visibleToClinic ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -132,33 +144,54 @@ export function LabCaseCommentsPanel({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canPost ? (
|
||||
<div className="space-y-2 border-t border-border pt-2">
|
||||
{canPost && deferSubmit ? (
|
||||
<div className="border-t border-border pt-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
value={composerValue ?? ''}
|
||||
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
{canToggleVisibility ? (
|
||||
<label className="flex items-center gap-2 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleToClinic}
|
||||
onChange={(e) => setVisibleToClinic(e.target.checked)}
|
||||
</div>
|
||||
) : canPost ? (
|
||||
<div className="flex items-end gap-2 border-t border-border pt-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
placeholder={t('placeholder')}
|
||||
rows={2}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
{t('visibleToClinicToggle')}
|
||||
</label>
|
||||
) : null}
|
||||
<Button
|
||||
<div className="flex items-center gap-1 pb-1">
|
||||
{canToggleVisibility ? (
|
||||
<button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={posting || !body.trim()}
|
||||
onClick={() => void handlePost()}
|
||||
onClick={() => setVisibleToClinic((v) => !v)}
|
||||
className={`shrink-0 p-2 rounded-md border transition-colors ${
|
||||
visibleToClinic
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||
aria-pressed={visibleToClinic}
|
||||
>
|
||||
{t('post')}
|
||||
</Button>
|
||||
{visibleToClinic ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePost()}
|
||||
disabled={posting || !body.trim()}
|
||||
className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={t('send')}
|
||||
aria-label={t('send')}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
40
frontend/src/components/ui/lab/caseDetailUtils.ts
Normal file
40
frontend/src/components/ui/lab/caseDetailUtils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LabCaseDetail } from '@/types/cases';
|
||||
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
||||
|
||||
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
export function formatCaseDateTime(value: string | null, locale: string) {
|
||||
if (!value) return '—';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
||||
if (labCase.toothProsthesis.length > 0) {
|
||||
const byCode = new Map<string, string[]>();
|
||||
for (const row of labCase.toothProsthesis) {
|
||||
const teeth = byCode.get(row.prosthesisTypeCode) ?? [];
|
||||
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
|
||||
byCode.set(row.prosthesisTypeCode, teeth);
|
||||
}
|
||||
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
|
||||
prosthesisTypeCode,
|
||||
teeth,
|
||||
}));
|
||||
}
|
||||
return labCase.tasksByTooth.map((g) => ({
|
||||
prosthesisTypeCode: g.prosthesisTypeCode,
|
||||
teeth: g.teeth,
|
||||
}));
|
||||
}
|
||||
|
||||
export function latestCaseAttachment(labCase: LabCaseDetail) {
|
||||
if (!labCase.attachments.length) return null;
|
||||
return [...labCase.attachments].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0];
|
||||
}
|
||||
@@ -2,65 +2,30 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { canEditCases } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { organizationApi } from '@/lib/api/organization';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||
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';
|
||||
formatCaseDateTime,
|
||||
formatPatientName,
|
||||
} from '@/components/ui/lab/caseDetailUtils';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import type { CounterpartItemDto } from '@/lib/api/organization';
|
||||
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null, locale: string) {
|
||||
if (!value) return '—';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionCaseHistoryContentProps {
|
||||
connection: CounterpartItemDto;
|
||||
onBack: () => void;
|
||||
@@ -90,10 +55,12 @@ export function ConnectionCaseHistoryContent({
|
||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||
const [commentCount, setCommentCount] = useState(0);
|
||||
|
||||
const locale = user?.language ?? 'en';
|
||||
const isClinic = currentOrganization?.type === 'CLINIC';
|
||||
const canEditImportant = !isClinic && canEditCases(currentOrganization);
|
||||
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
@@ -194,33 +161,24 @@ export function ConnectionCaseHistoryContent({
|
||||
[],
|
||||
);
|
||||
|
||||
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]);
|
||||
async function handleCaseImportantToggle(isImportant: boolean) {
|
||||
if (!selectedCaseId || !canEditImportant || !selectedCase) return;
|
||||
|
||||
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);
|
||||
const previousCase = selectedCase;
|
||||
setSelectedCase({ ...selectedCase, isImportant });
|
||||
|
||||
setUpdatingImportant(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
||||
setSelectedCase(response.data);
|
||||
} catch (error: unknown) {
|
||||
setSelectedCase(previousCase);
|
||||
showError(formatApiErrorMessage(error, tCases('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingImportant(false);
|
||||
}
|
||||
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">
|
||||
@@ -286,13 +244,13 @@ export function ConnectionCaseHistoryContent({
|
||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{formatDateTime(item.sentAt, locale)}
|
||||
{formatCaseDateTime(item.sentAt, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<TaskProgressBar
|
||||
<CaseTaskProgressBar
|
||||
completed={item.taskProgress.completed}
|
||||
total={item.taskProgress.total}
|
||||
/>
|
||||
@@ -340,25 +298,20 @@ export function ConnectionCaseHistoryContent({
|
||||
) : loadingDetail || !selectedCase ? (
|
||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1 border-b border-border pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{formatPatientName(selectedCase.patient)}
|
||||
</h2>
|
||||
{isClinic ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||
{commentCount > 0
|
||||
? tCases('commentsCount', { count: commentCount })
|
||||
: tCases('showComments')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">
|
||||
{tCases('patientMobile')}: {selectedCase.patient.mobile}
|
||||
</p>
|
||||
{!isClinic ? (
|
||||
<CaseDetailPanel
|
||||
labCase={selectedCase}
|
||||
locale={locale}
|
||||
treatmentLabel={treatmentLabel}
|
||||
statusOptions={statusOptions}
|
||||
loadAttachmentBlob={loadClinicAttachmentBlob}
|
||||
showCommentsButton={isClinic}
|
||||
commentCount={commentCount}
|
||||
onCommentsClick={scrollToComments}
|
||||
canEditImportant={canEditImportant}
|
||||
updatingImportant={updatingImportant}
|
||||
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||
headerMetaLines={
|
||||
!isClinic ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
{tCases('fromClinic', { name: selectedCase.clinic.name })}
|
||||
</p>
|
||||
@@ -366,121 +319,10 @@ export function ConnectionCaseHistoryContent({
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('caseHistorySentToLab', { name: connection.organizationName })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-text-muted">
|
||||
{tCases('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
||||
</p>
|
||||
<div className="pt-1 max-w-xs">
|
||||
<p className="text-sm text-text-muted mb-1">
|
||||
{tCases('taskProgressLabel', {
|
||||
completed: selectedCase.taskProgress.completed,
|
||||
total: selectedCase.taskProgress.total,
|
||||
})}
|
||||
</p>
|
||||
<TaskProgressBar
|
||||
completed={selectedCase.taskProgress.completed}
|
||||
total={selectedCase.taskProgress.total}
|
||||
/>
|
||||
</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">
|
||||
{tCases('treatmentDetails')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{selectedCase.details.map((detail) => (
|
||||
<li
|
||||
key={detail.id}
|
||||
className="rounded-md bg-background border border-border p-2"
|
||||
>
|
||||
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
|
||||
<div className="text-text-muted">
|
||||
{tCases('teethLabel')}: {detail.teeth.join(', ') || '—'}
|
||||
</div>
|
||||
{detail.comment ? (
|
||||
<div className="text-text-muted mt-1">{detail.comment}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{tCases('tasksByTooth')}</h3>
|
||||
{selectedCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{tCases('noTasks')}</p>
|
||||
) : (
|
||||
selectedCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
truncate
|
||||
title={group.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{tCases('toothGroupTitle', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex flex-wrap items-center gap-2 text-sm rounded bg-background p-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
{task.lastStatusChangedBy ? (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isClinic && selectedCaseId ? (
|
||||
)
|
||||
}
|
||||
commentsSection={
|
||||
isClinic && selectedCaseId ? (
|
||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||
<LabCaseCommentsPanel
|
||||
caseId={selectedCaseId}
|
||||
@@ -506,8 +348,9 @@ export function ConnectionCaseHistoryContent({
|
||||
onError={showError}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ interface LabCasesDispatchPanelProps {
|
||||
onRecentOrganizationPick: (orgId: string) => void;
|
||||
sendBusyId: string | null;
|
||||
onAddLabCase: () => void;
|
||||
onSendLabCase: (labCase: LabCaseDraft) => void;
|
||||
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
|
||||
onCommentError?: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export function LabCasesDispatchPanel({
|
||||
const t = useTranslations('treatment');
|
||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
const filteredOrganizations = (() => {
|
||||
@@ -196,6 +197,11 @@ export function LabCasesDispatchPanel({
|
||||
};
|
||||
}, [activeLabCase?.destinationOrganizationId]);
|
||||
|
||||
// Reset the pending (unposted) comment when switching to another shipment.
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
// Hide dispatch when the selected treatment detail is not lab-dependent.
|
||||
if (!activeDetail || !isLabDependentDetail) {
|
||||
return null;
|
||||
@@ -428,6 +434,9 @@ export function LabCasesDispatchPanel({
|
||||
caseId={activeLabCase.id}
|
||||
canPost={canEdit && !disabled}
|
||||
canToggleVisibility={false}
|
||||
deferSubmit
|
||||
composerValue={pendingComment}
|
||||
onComposerValueChange={setPendingComment}
|
||||
loadComments={async () => {
|
||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||
return r.data;
|
||||
@@ -578,7 +587,7 @@ export function LabCasesDispatchPanel({
|
||||
!prosthesisComplete
|
||||
}
|
||||
isLoading={sendBusyId === activeLabCase.clientId}
|
||||
onClick={() => onSendLabCase(activeLabCase)}
|
||||
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||
>
|
||||
{t('sendToLab')}
|
||||
</Button>
|
||||
|
||||
@@ -109,13 +109,24 @@ function buildWorkspaceSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function newDetail(): TreatmentDetailDraft {
|
||||
function defaultTreatmentTypeForAppointment(
|
||||
purpose: string | undefined,
|
||||
catalog: TreatmentCatalogEntry[],
|
||||
): TreatmentDetailDraft['treatmentType'] {
|
||||
const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment);
|
||||
if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) {
|
||||
return purpose as TreatmentDetailDraft['treatmentType'];
|
||||
}
|
||||
return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType'];
|
||||
}
|
||||
|
||||
function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft {
|
||||
return {
|
||||
clientId:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
treatmentType: 'restoration',
|
||||
treatmentType: defaultTreatmentType ?? 'restoration',
|
||||
teeth: [],
|
||||
comment: '',
|
||||
attachmentMetas: [],
|
||||
@@ -502,9 +513,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}, [showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAppointment?.patientId) {
|
||||
setHistoryPatientId(selectedAppointment.patientId);
|
||||
if (!selectedAppointment?.patientId) {
|
||||
setHistoryPatientId(null);
|
||||
setHistory([]);
|
||||
setHistoryLoading(false);
|
||||
return;
|
||||
}
|
||||
setHistoryPatientId(selectedAppointment.patientId);
|
||||
}, [selectedAppointment?.patientId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -558,7 +573,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
const first = newDetail();
|
||||
const first = newDetail(
|
||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||
);
|
||||
setDetails([first]);
|
||||
setActiveDetailId(first.clientId);
|
||||
setSavedSnapshot(serializeDetails([first]));
|
||||
@@ -583,7 +600,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
cancelled = true;
|
||||
draftHydratingRef.current = false;
|
||||
};
|
||||
}, [selectedAppointment?.id, workspaceMode, showError, t]);
|
||||
}, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
|
||||
|
||||
const persistDraft = useCallback(
|
||||
async (options?: { force?: boolean }) => {
|
||||
@@ -905,7 +922,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
]);
|
||||
|
||||
const handleSendLabCase = useCallback(
|
||||
async (labCase: LabCaseDraft) => {
|
||||
async (labCase: LabCaseDraft, comment?: string) => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
if (!labCase.destinationOrganizationId) {
|
||||
showError(t('errorChooseOrg'));
|
||||
@@ -934,6 +951,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
);
|
||||
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
|
||||
|
||||
const trimmedComment = comment?.trim();
|
||||
if (trimmedComment) {
|
||||
await treatmentsApi.addLabCaseComment(refreshedLabCase.id, { body: trimmedComment });
|
||||
}
|
||||
|
||||
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||
|
||||
const sentDetailClientIds = new Set(labCase.detailClientIds);
|
||||
@@ -1117,7 +1139,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
saveStatus={saveStatus}
|
||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||
onAddDetail={() => {
|
||||
const next = newDetail();
|
||||
const next = newDetail(
|
||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
}}
|
||||
@@ -1151,7 +1175,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onAddLabCase={() => void handleAddLabCase()}
|
||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
||||
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
|
||||
onCommentError={showError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { apiClient } from './client';
|
||||
import type {
|
||||
CasesFilterOptions,
|
||||
LabCaseDetail,
|
||||
LabCaseTask,
|
||||
ListLabCasesParams,
|
||||
PaginatedLabCases,
|
||||
} from '@/types/cases';
|
||||
@@ -25,12 +24,11 @@ export const casesApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setTaskImportant: async (
|
||||
setCaseImportant: async (
|
||||
caseId: string,
|
||||
taskId: string,
|
||||
isImportant: boolean,
|
||||
): Promise<{ success: boolean; data: LabCaseTask }> => {
|
||||
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
|
||||
): Promise<{ success: boolean; data: LabCaseDetail }> => {
|
||||
const response = await apiClient.patch(`/cases/${caseId}/important`, { isImportant });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface LabCaseTask {
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
isImportant: boolean;
|
||||
createdAt: string;
|
||||
lastStatusChangedAt: string | null;
|
||||
lastStatusChangedBy: LabTaskUser | null;
|
||||
@@ -77,6 +76,7 @@ export interface LabCaseAttachmentMeta {
|
||||
export interface LabCaseDetail {
|
||||
id: string;
|
||||
sentAt: string | null;
|
||||
isImportant: boolean;
|
||||
clinic: { id: string; name: string };
|
||||
patient: {
|
||||
id: string;
|
||||
@@ -133,7 +133,14 @@ export interface PaginatedLabCases {
|
||||
};
|
||||
}
|
||||
|
||||
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
|
||||
export type TaskSortField =
|
||||
| 'date'
|
||||
| 'status'
|
||||
| 'clinic'
|
||||
| 'patient'
|
||||
| 'important'
|
||||
| 'prosthesis'
|
||||
| 'taskType';
|
||||
|
||||
export interface ListLabTasksParams {
|
||||
q?: string;
|
||||
|
||||
Reference in New Issue
Block a user