From 8d334833ff77355b86fe23a5b9aab50103f34129 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 8 Jul 2026 02:53:47 +0330 Subject: [PATCH] improvement: all demo bugs fixed. give me more baby. --- .../migration.sql | 13 + backend/prisma/schema.prisma | 3 +- backend/src/modules/cases/cases.controller.ts | 13 +- backend/src/modules/cases/cases.service.ts | 48 +-- backend/src/modules/cases/dto/cases.dto.ts | 2 +- backend/src/modules/tasks/dto/tasks.dto.ts | 11 +- backend/src/modules/tasks/tasks.service.ts | 23 +- frontend/messages/en.json | 16 +- frontend/messages/fa.json | 15 +- frontend/messages/nl.json | 15 +- .../app/[locale]/(dashboard)/cases/page.tsx | 322 ++++-------------- .../app/[locale]/(dashboard)/tasks/page.tsx | 49 +-- .../src/components/ui/lab/CaseDetailPanel.tsx | 245 +++++++++++++ .../components/ui/lab/CaseToothChartPanel.tsx | 6 +- .../ui/lab/LabCaseAttachmentPreview.tsx | 2 +- .../ui/lab/LabCaseAttachmentsDialog.tsx | 211 ++++++++++++ .../ui/lab/LabCaseCommentsPanel.tsx | 97 ++++-- .../src/components/ui/lab/caseDetailUtils.ts | 40 +++ .../ConnectionCaseHistoryContent.tsx | 297 ++++------------ .../ui/treatment/LabCasesDispatchPanel.tsx | 13 +- .../ui/treatment/TreatmentWorkspace.tsx | 42 ++- frontend/src/lib/api/cases.ts | 8 +- frontend/src/types/cases.ts | 11 +- 23 files changed, 876 insertions(+), 626 deletions(-) create mode 100644 backend/prisma/migrations/20260708120000_case_level_important/migration.sql create mode 100644 frontend/src/components/ui/lab/CaseDetailPanel.tsx create mode 100644 frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx create mode 100644 frontend/src/components/ui/lab/caseDetailUtils.ts diff --git a/backend/prisma/migrations/20260708120000_case_level_important/migration.sql b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql new file mode 100644 index 0000000..ee816ed --- /dev/null +++ b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql @@ -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"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3e7c2df..1719f11 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 8b319d6..d54a7b1 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -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); } } diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index d7fe45c..5d7c482 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -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: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + 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 diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index 6e567fd..afa969b 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -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; } diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 9898378..c1ffe75 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -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() diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 9c15b51..e3ded96 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -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 } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 532a3e0..2ee0e28 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index d8e53b3..53b41c7 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "نوبت‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 786520f..fb34543 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index c274887..da9bcf5 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -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 ( -
-
- {completed}/{total} - {pct}% -
-
-
-
-
- ); -} - 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(null); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); - const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [updatingImportant, setUpdatingImportant] = useState(false); const [commentCount, setCommentCount] = useState(0); const canEdit = canEditCases(currentOrganization); @@ -152,17 +116,23 @@ export default function CasesPage() { } }; - const loadDetail = async (caseId: string) => { - setLoadingDetail(true); + 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'))); - setSelectedCase(null); + if (!options?.silent) { + setSelectedCase(null); + } } finally { - setLoadingDetail(false); + if (!options?.silent) { + setLoadingDetail(false); + } } }; @@ -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(); - 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() {
{item.clinic.name}
- {formatDateTime(item.sentAt, locale)} + {formatCaseDateTime(item.sentAt, locale)}
{item.treatmentTypes.map(treatmentLabel).join(', ')}
- @@ -445,181 +391,53 @@ export default function CasesPage() { ) : loadingDetail || !selectedCase ? (

{tCommon('loading')}

) : ( -
-
-
-

- {formatPatientName(selectedCase.patient)} -

- -
-

- {t('patientMobile')}: {selectedCase.patient.mobile} -

+ void handleCaseImportantToggle(checked)} + headerMetaLines={

{t('fromClinic', { name: selectedCase.clinic.name })}

-

- {t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })} -

-
-

- {t('taskProgressLabel', { - completed: selectedCase.taskProgress.completed, - total: selectedCase.taskProgress.total, - })} -

- -
-
- -
- - {latestCaseAttachment && selectedCaseId ? ( -
-

{t('latestAttachment')}

- + { + const r = await tasksApi.listComments(selectedCaseId); + setCommentCount(r.data.length); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(selectedCaseId, { + body, + visibleToClinic, + }); + setCommentCount((n) => n + 1); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={toast.showError} /> -
- ) : null} -
- - {selectedCase.details.length > 0 && ( -
-

{t('treatmentDetails')}

-
    - {selectedCase.details.map((detail) => ( -
  • -
    {treatmentLabel(detail.treatmentType)}
    -
    - {t('teethLabel')}: {detail.teeth.join(', ') || '—'} -
    - {detail.comment ? ( -
    {detail.comment}
    - ) : null} -
  • - ))} -
-
- )} - -
-

{t('tasksByTooth')}

- {selectedCase.tasksByTooth.length === 0 ? ( -

{t('noTasks')}

- ) : ( - selectedCase.tasksByTooth.map((group, groupIndex) => ( -
-
- - {group.prosthesisTypeLabel} - - - {t('toothGroupTitle', { - teeth: formatToothList(group.teeth), - prosthesis: group.prosthesisTypeLabel, - })} - -
-
    - {group.tasks.map((task) => ( -
  • -
    - - {task.stepOrder}. {task.stepLabel} - - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - {canEdit ? ( - - ) : task.isImportant ? ( - - {t('importantLabel')} - - ) : null} -
    -

    - {task.lastStatusChangedBy - ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) - : t('lastUpdatedUnknown')} - {task.lastStatusChangedAt - ? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}` - : ''} -

    -
  • - ))} -
-
- )) - )} -
- - {selectedCaseId ? ( -
- { - const r = await tasksApi.listComments(selectedCaseId); - setCommentCount(r.data.length); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(selectedCaseId, { - body, - visibleToClinic, - }); - setCommentCount((n) => n + 1); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={toast.showError} - /> -
- ) : null} -
+ + ) : null + } + /> )}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 64e9d36..91c47bf 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -51,13 +51,11 @@ export default function TasksPage() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); - const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null); + const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(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('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(); @@ -228,10 +219,10 @@ export default function TasksPage() { className={`${filterSelectClass} min-w-0 flex-1`} > - - + + { - setShowCompleted(e.target.checked); - setPage(1); - }} - /> - {t('showCompleted')} - - -
@@ -279,7 +246,7 @@ export default function TasksPage() { ) : (
    {tasks.map((task, index) => { - const commentsOpen = expandedCommentsCaseId === task.labCaseId; + const commentsOpen = expandedCommentsTaskId === task.id; return (
  • @@ -343,7 +310,7 @@ export default function TasksPage() { + ) : null} + {canEditImportant ? ( + onImportantChange?.(checked)} + /> + ) : null} + {previewAttachment && labCase.attachments.length > 0 ? ( + + ) : null} + + + + + + {labCase.details.length > 0 ? ( +
    +

    {t('treatmentDetails')}

    +
      + {labCase.details.map((detail) => ( +
    • +
      {treatmentLabel(detail.treatmentType)}
      +
      + {t('teethLabel')}: {detail.teeth.join(', ') || '—'} +
      + {detail.comment ? ( +
      {detail.comment}
      + ) : null} +
    • + ))} +
    +
    + ) : null} + +
    +

    {t('tasksByTooth')}

    + {labCase.tasksByTooth.length === 0 ? ( +

    {t('noTasks')}

    + ) : ( + labCase.tasksByTooth.map((group, groupIndex) => ( +
    +
    + + {group.prosthesisTypeLabel} + + + {t('toothGroupTitle', { + teeth: formatToothList(group.teeth), + prosthesis: group.prosthesisTypeLabel, + })} + +
    +
      + {group.tasks.map((task) => ( +
    • +
      + + {task.stepOrder}. {task.stepLabel} + + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + +
      +

      + {task.lastStatusChangedBy + ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) + : t('lastUpdatedUnknown')} + {task.lastStatusChangedAt + ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` + : ''} +

      +
    • + ))} +
    +
    + )) + )} +
    + + {commentsSection} + + setAttachmentsDialogOpen(false)} + caseId={labCase.id} + attachments={labCase.attachments} + loadBlob={loadAttachmentBlob} + /> + + ); +} + +export { CaseTaskProgressBar }; diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index b9fd80d..acc4513 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -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} /> ); diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx index 00a2f82..1fcc5b3 100644 --- a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx +++ b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx @@ -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(null); const [failed, setFailed] = useState(false); diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx new file mode 100644 index 0000000..e6a63fa --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseAttachmentsDialog.tsx @@ -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; +} + +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; + onDownload: (blob: Blob, fileName: string) => void; +}) { + const t = useTranslations('cases'); + const [url, setUrl] = useState(null); + const [blob, setBlob] = useState(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 ( +
    +
    + {url && isImage ? ( + {attachment.fileName} + ) : url && isPdf ? ( +