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
|
sortOrder Int
|
||||||
destinationOrganizationId String?
|
destinationOrganizationId String?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
|
isImportant Boolean @default(false)
|
||||||
|
|
||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
details LabCaseDetail[]
|
details LabCaseDetail[]
|
||||||
@@ -333,7 +334,6 @@ model LabCaseTask {
|
|||||||
workflowStepCode String
|
workflowStepCode String
|
||||||
stepOrder Int
|
stepOrder Int
|
||||||
stepLabel String
|
stepLabel String
|
||||||
isImportant Boolean @default(false)
|
|
||||||
status LabTaskStatus @default(IN_PROGRESS)
|
status LabTaskStatus @default(IN_PROGRESS)
|
||||||
lastStatusChangedByUserId String?
|
lastStatusChangedByUserId String?
|
||||||
lastStatusChangedAt DateTime?
|
lastStatusChangedAt DateTime?
|
||||||
@@ -348,7 +348,6 @@ model LabCaseTask {
|
|||||||
|
|
||||||
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
@@index([labCaseId, isImportant])
|
|
||||||
@@map("lab_case_tasks")
|
@@map("lab_case_tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CasesService } from './cases.service';
|
import { CasesService } from './cases.service';
|
||||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
|
||||||
|
|
||||||
@ApiTags('cases')
|
@ApiTags('cases')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -64,15 +64,14 @@ export class CasesController {
|
|||||||
file.stream.pipe(res);
|
file.stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/tasks/:taskId')
|
@Patch(':id/important')
|
||||||
@ApiOperation({ summary: 'Toggle task important flag' })
|
@ApiOperation({ summary: 'Toggle the important flag for a whole case' })
|
||||||
updateTask(
|
setCaseImportant(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('taskId') taskId: string,
|
@Body() dto: UpdateLabCaseImportantDto,
|
||||||
@Body() dto: UpdateLabCaseTaskDto,
|
|
||||||
@Req() req,
|
@Req() req,
|
||||||
) {
|
) {
|
||||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
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';
|
} from '../catalog/catalog-label.service';
|
||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
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';
|
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||||
|
|
||||||
const labCaseListInclude = {
|
const labCaseListInclude = {
|
||||||
@@ -335,51 +335,39 @@ export class CasesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTask(
|
async setCaseImportant(
|
||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
taskId: string,
|
dto: UpdateLabCaseImportantDto,
|
||||||
dto: UpdateLabCaseTaskDto,
|
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
localeInput?: string | null,
|
localeInput?: string | null,
|
||||||
) {
|
) {
|
||||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
const task = await this.prisma.labCaseTask.findFirst({
|
const existing = await this.prisma.labCase.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: taskId,
|
id: labCaseId,
|
||||||
labCaseId,
|
sentAt: { not: null },
|
||||||
labCase: {
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
sentAt: { not: null },
|
|
||||||
sends: { some: { organizationId: labOrganizationId } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!task) {
|
if (!existing) {
|
||||||
throw new NotFoundException('Task not found');
|
throw new NotFoundException('Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.labCaseTask.update({
|
await this.prisma.labCase.update({
|
||||||
where: { id: taskId },
|
where: { id: labCaseId },
|
||||||
data: { isImportant: dto.isImportant },
|
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 labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
where: { id: labCaseId },
|
||||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
include: labCaseListInclude,
|
||||||
[updated.prosthesisTypeCode],
|
});
|
||||||
locale,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
|
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildListWhere(
|
private buildListWhere(
|
||||||
@@ -499,6 +487,7 @@ export class CasesService {
|
|||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
|
isImportant: lc.isImportant,
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: lc.treatment.patient,
|
patient: lc.treatment.patient,
|
||||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||||
@@ -585,7 +574,6 @@ export class CasesService {
|
|||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
isImportant: task.isImportant,
|
|
||||||
createdAt: task.createdAt.toISOString(),
|
createdAt: task.createdAt.toISOString(),
|
||||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||||
lastStatusChangedBy: task.lastStatusChangedBy
|
lastStatusChangedBy: task.lastStatusChangedBy
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateLabCaseTaskDto {
|
export class UpdateLabCaseImportantDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ export class UpdateLabTaskDto {
|
|||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
|
export type TaskSortField =
|
||||||
|
| 'date'
|
||||||
|
| 'status'
|
||||||
|
| 'clinic'
|
||||||
|
| 'patient'
|
||||||
|
| 'important'
|
||||||
|
| 'prosthesis'
|
||||||
|
| 'taskType';
|
||||||
|
|
||||||
export class ListLabTasksDto {
|
export class ListLabTasksDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -59,7 +66,7 @@ export class ListLabTasksDto {
|
|||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -188,9 +188,9 @@ export class TasksService {
|
|||||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||||
: {}),
|
: {}),
|
||||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||||
|
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||||
},
|
},
|
||||||
...(status !== undefined ? { status } : {}),
|
...(status !== undefined ? { status } : {}),
|
||||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,15 +229,24 @@ export class TasksService {
|
|||||||
{ id: 'asc' },
|
{ id: 'asc' },
|
||||||
];
|
];
|
||||||
case 'important':
|
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':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
|
// date / caseId / taskId / stepId — newest first by default.
|
||||||
return [
|
return [
|
||||||
{ labCase: { sentAt: dir } },
|
{ labCase: { sentAt: dir } },
|
||||||
{ labCaseId: 'asc' },
|
{ labCaseId: dir },
|
||||||
{ treatmentDetailId: 'asc' },
|
{ id: dir },
|
||||||
{ stepOrder: 'asc' },
|
{ stepOrder: dir },
|
||||||
{ id: 'asc' },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +268,7 @@ export class TasksService {
|
|||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
isImportant: task.isImportant,
|
isImportant: task.labCase.isImportant,
|
||||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||||
lastStatusChangedBy: task.lastStatusChangedBy
|
lastStatusChangedBy: task.lastStatusChangedBy
|
||||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||||
|
|||||||
@@ -335,6 +335,7 @@
|
|||||||
"statusInProgress": "In progress",
|
"statusInProgress": "In progress",
|
||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"importantLabel": "Important",
|
"importantLabel": "Important",
|
||||||
|
"markCaseImportant": "Mark case as important",
|
||||||
"markImportant": "Mark as important",
|
"markImportant": "Mark as important",
|
||||||
"lastUpdatedBy": "Updated by {name}",
|
"lastUpdatedBy": "Updated by {name}",
|
||||||
"lastUpdatedUnknown": "Not started yet",
|
"lastUpdatedUnknown": "Not started yet",
|
||||||
@@ -353,7 +354,13 @@
|
|||||||
"patientMobile": "Mobile",
|
"patientMobile": "Mobile",
|
||||||
"showComments": "Comments",
|
"showComments": "Comments",
|
||||||
"commentsCount": "Comments ({count})",
|
"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",
|
"prevPage": "Previous",
|
||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||||
@@ -391,6 +398,8 @@
|
|||||||
"sortClinic": "Clinic",
|
"sortClinic": "Clinic",
|
||||||
"sortPatient": "Patient",
|
"sortPatient": "Patient",
|
||||||
"sortImportant": "Important",
|
"sortImportant": "Important",
|
||||||
|
"sortProsthesis": "Prosthesis type",
|
||||||
|
"sortTaskType": "Task type",
|
||||||
"sortDirection": "Sort direction",
|
"sortDirection": "Sort direction",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"commentsButton": "Comments",
|
"commentsButton": "Comments",
|
||||||
@@ -413,7 +422,10 @@
|
|||||||
"clinicAuthor": "Clinic",
|
"clinicAuthor": "Clinic",
|
||||||
"errorLoad": "Failed to load comments.",
|
"errorLoad": "Failed to load comments.",
|
||||||
"errorPost": "Failed to post comment.",
|
"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": {
|
"appointments": {
|
||||||
"title": "Appointments",
|
"title": "Appointments",
|
||||||
|
|||||||
@@ -335,6 +335,7 @@
|
|||||||
"statusInProgress": "در حال انجام",
|
"statusInProgress": "در حال انجام",
|
||||||
"statusCompleted": "انجام شده",
|
"statusCompleted": "انجام شده",
|
||||||
"importantLabel": "مهم",
|
"importantLabel": "مهم",
|
||||||
|
"markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
|
||||||
"markImportant": "علامتگذاری به عنوان مهم",
|
"markImportant": "علامتگذاری به عنوان مهم",
|
||||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||||
"lastUpdatedUnknown": "هنوز شروع نشده",
|
"lastUpdatedUnknown": "هنوز شروع نشده",
|
||||||
@@ -354,6 +355,13 @@
|
|||||||
"showComments": "نظرات",
|
"showComments": "نظرات",
|
||||||
"commentsCount": "نظرات ({count})",
|
"commentsCount": "نظرات ({count})",
|
||||||
"latestAttachment": "آخرین فایل",
|
"latestAttachment": "آخرین فایل",
|
||||||
|
"viewAttachments": "مشاهده همه پیوستها",
|
||||||
|
"attachmentsDialogTitle": "پیوستهای پرونده",
|
||||||
|
"attachmentsDialogSubtitle": "پیشنمایش و دانلود فایلهای بهاشتراکگذاشتهشده با این پرونده.",
|
||||||
|
"noAttachments": "هیچ پیوستی با این پرونده بهاشتراک گذاشته نشده است.",
|
||||||
|
"downloadAttachment": "دانلود",
|
||||||
|
"downloadAllAttachments": "دانلود همه",
|
||||||
|
"attachmentPreviewUnavailable": "پیشنمایش در دسترس نیست",
|
||||||
"prevPage": "قبلی",
|
"prevPage": "قبلی",
|
||||||
"nextPage": "بعدی",
|
"nextPage": "بعدی",
|
||||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||||
@@ -391,6 +399,8 @@
|
|||||||
"sortClinic": "کلینیک",
|
"sortClinic": "کلینیک",
|
||||||
"sortPatient": "بیمار",
|
"sortPatient": "بیمار",
|
||||||
"sortImportant": "مهم",
|
"sortImportant": "مهم",
|
||||||
|
"sortProsthesis": "نوع پروتز",
|
||||||
|
"sortTaskType": "نوع کار",
|
||||||
"sortDirection": "جهت مرتبسازی",
|
"sortDirection": "جهت مرتبسازی",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
"commentsButton": "نظرات",
|
"commentsButton": "نظرات",
|
||||||
@@ -413,7 +423,10 @@
|
|||||||
"clinicAuthor": "کلینیک",
|
"clinicAuthor": "کلینیک",
|
||||||
"errorLoad": "بارگذاری نظرات ناموفق بود.",
|
"errorLoad": "بارگذاری نظرات ناموفق بود.",
|
||||||
"errorPost": "ثبت نظر ناموفق بود.",
|
"errorPost": "ثبت نظر ناموفق بود.",
|
||||||
"errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود."
|
"errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود.",
|
||||||
|
"send": "ارسال نظر",
|
||||||
|
"composerVisible": "قابل مشاهده برای کلینیک",
|
||||||
|
"composerHidden": "پنهان از کلینیک"
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "نوبتها",
|
"title": "نوبتها",
|
||||||
|
|||||||
@@ -335,6 +335,7 @@
|
|||||||
"statusInProgress": "Bezig",
|
"statusInProgress": "Bezig",
|
||||||
"statusCompleted": "Voltooid",
|
"statusCompleted": "Voltooid",
|
||||||
"importantLabel": "Belangrijk",
|
"importantLabel": "Belangrijk",
|
||||||
|
"markCaseImportant": "Zaak als belangrijk markeren",
|
||||||
"markImportant": "Markeren als belangrijk",
|
"markImportant": "Markeren als belangrijk",
|
||||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||||
"lastUpdatedUnknown": "Nog niet gestart",
|
"lastUpdatedUnknown": "Nog niet gestart",
|
||||||
@@ -354,6 +355,13 @@
|
|||||||
"showComments": "Opmerkingen",
|
"showComments": "Opmerkingen",
|
||||||
"commentsCount": "Opmerkingen ({count})",
|
"commentsCount": "Opmerkingen ({count})",
|
||||||
"latestAttachment": "Laatste bestand",
|
"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",
|
"prevPage": "Vorige",
|
||||||
"nextPage": "Volgende",
|
"nextPage": "Volgende",
|
||||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||||
@@ -391,6 +399,8 @@
|
|||||||
"sortClinic": "Kliniek",
|
"sortClinic": "Kliniek",
|
||||||
"sortPatient": "Patiënt",
|
"sortPatient": "Patiënt",
|
||||||
"sortImportant": "Belangrijk",
|
"sortImportant": "Belangrijk",
|
||||||
|
"sortProsthesis": "Prothesetype",
|
||||||
|
"sortTaskType": "Taaktype",
|
||||||
"sortDirection": "Sorteerrichting",
|
"sortDirection": "Sorteerrichting",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
"commentsButton": "Opmerkingen",
|
"commentsButton": "Opmerkingen",
|
||||||
@@ -413,7 +423,10 @@
|
|||||||
"clinicAuthor": "Kliniek",
|
"clinicAuthor": "Kliniek",
|
||||||
"errorLoad": "Opmerkingen laden mislukt.",
|
"errorLoad": "Opmerkingen laden mislukt.",
|
||||||
"errorPost": "Opmerking plaatsen 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": {
|
"appointments": {
|
||||||
"title": "Afspraken",
|
"title": "Afspraken",
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
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 { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
import {
|
||||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
formatCaseDateTime,
|
||||||
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
|
formatPatientName,
|
||||||
|
} from '@/components/ui/lab/caseDetailUtils';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
@@ -29,47 +29,11 @@ import type {
|
|||||||
LabTaskStatus,
|
LabTaskStatus,
|
||||||
PaginatedLabCases,
|
PaginatedLabCases,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
import {
|
|
||||||
formatToothList,
|
|
||||||
prosthesisTypeBadgeStyle,
|
|
||||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
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() {
|
export default function CasesPage() {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
const tTreatment = useTranslations('treatment');
|
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization, user } = useAuth();
|
const { currentOrganization, user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -99,7 +63,7 @@ export default function CasesPage() {
|
|||||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = 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 [commentCount, setCommentCount] = useState(0);
|
||||||
|
|
||||||
const canEdit = canEditCases(currentOrganization);
|
const canEdit = canEditCases(currentOrganization);
|
||||||
@@ -152,17 +116,23 @@ export default function CasesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadDetail = async (caseId: string) => {
|
const loadDetail = async (caseId: string, options?: { silent?: boolean }) => {
|
||||||
setLoadingDetail(true);
|
if (!options?.silent) {
|
||||||
|
setLoadingDetail(true);
|
||||||
|
}
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
const response = await casesApi.getOne(caseId);
|
const response = await casesApi.getOne(caseId);
|
||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
|
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
|
||||||
setSelectedCase(null);
|
if (!options?.silent) {
|
||||||
|
setSelectedCase(null);
|
||||||
|
}
|
||||||
} finally {
|
} 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<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() {
|
function clearFilters() {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setClinicId('');
|
setClinicId('');
|
||||||
@@ -254,18 +196,22 @@ export default function CasesPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImportantToggle(taskId: string, isImportant: boolean) {
|
async function handleCaseImportantToggle(isImportant: boolean) {
|
||||||
if (!selectedCaseId || !canEdit) return;
|
if (!selectedCaseId || !canEdit || !selectedCase) return;
|
||||||
|
|
||||||
setUpdatingTaskId(taskId);
|
const previousCase = selectedCase;
|
||||||
|
setSelectedCase({ ...selectedCase, isImportant });
|
||||||
|
|
||||||
|
setUpdatingImportant(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
|
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
||||||
await loadDetail(selectedCaseId);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
setSelectedCase(previousCase);
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingTaskId(null);
|
setUpdatingImportant(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +337,13 @@ export default function CasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||||
<div className="text-xs text-text-muted mt-1">
|
<div className="text-xs text-text-muted mt-1">
|
||||||
{formatDateTime(item.sentAt, locale)}
|
{formatCaseDateTime(item.sentAt, locale)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-1 truncate">
|
<div className="text-xs text-text-muted mt-1 truncate">
|
||||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<TaskProgressBar
|
<CaseTaskProgressBar
|
||||||
completed={item.taskProgress.completed}
|
completed={item.taskProgress.completed}
|
||||||
total={item.taskProgress.total}
|
total={item.taskProgress.total}
|
||||||
/>
|
/>
|
||||||
@@ -445,181 +391,53 @@ export default function CasesPage() {
|
|||||||
) : loadingDetail || !selectedCase ? (
|
) : loadingDetail || !selectedCase ? (
|
||||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<CaseDetailPanel
|
||||||
<header className="space-y-1 border-b border-border pb-3">
|
labCase={selectedCase}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
locale={locale}
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
treatmentLabel={treatmentLabel}
|
||||||
{formatPatientName(selectedCase.patient)}
|
statusOptions={statusOptions}
|
||||||
</h2>
|
loadAttachmentBlob={loadCaseAttachmentBlob}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
showCommentsButton
|
||||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
commentCount={commentCount}
|
||||||
{commentCount > 0
|
onCommentsClick={scrollToComments}
|
||||||
? t('commentsCount', { count: commentCount })
|
canEditImportant={canEdit}
|
||||||
: t('showComments')}
|
updatingImportant={updatingImportant}
|
||||||
</Button>
|
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||||
</div>
|
headerMetaLines={
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
{t('patientMobile')}: {selectedCase.patient.mobile}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('fromClinic', { name: selectedCase.clinic.name })}
|
{t('fromClinic', { name: selectedCase.clinic.name })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-muted">
|
}
|
||||||
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
commentsSection={
|
||||||
</p>
|
selectedCaseId ? (
|
||||||
<div className="pt-1 max-w-xs">
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
<p className="text-sm text-text-muted mb-1">
|
<LabCaseCommentsPanel
|
||||||
{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}
|
caseId={selectedCaseId}
|
||||||
attachment={latestCaseAttachment}
|
canPost={canEditComments}
|
||||||
loadBlob={loadCaseAttachmentBlob}
|
canToggleVisibility={canEditComments}
|
||||||
|
loadComments={async () => {
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
) : null}
|
) : 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 ? (
|
|
||||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
|
||||||
<LabCaseCommentsPanel
|
|
||||||
caseId={selectedCaseId}
|
|
||||||
canPost={canEditComments}
|
|
||||||
canToggleVisibility={canEditComments}
|
|
||||||
loadComments={async () => {
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,13 +51,11 @@ export default function TasksPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
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 [search, setSearch] = useState('');
|
||||||
const [clinicId, setClinicId] = useState('');
|
const [clinicId, setClinicId] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
|
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
|
||||||
const [importantOnly, setImportantOnly] = useState(false);
|
|
||||||
const [sentFrom, setSentFrom] = useState('');
|
const [sentFrom, setSentFrom] = useState('');
|
||||||
const [sentTo, setSentTo] = useState('');
|
const [sentTo, setSentTo] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
||||||
@@ -87,18 +85,11 @@ export default function TasksPage() {
|
|||||||
};
|
};
|
||||||
if (search.trim()) params.q = search.trim();
|
if (search.trim()) params.q = search.trim();
|
||||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||||
if (statusFilter) {
|
if (statusFilter) params.status = statusFilter;
|
||||||
params.status = statusFilter;
|
|
||||||
} else if (showCompleted) {
|
|
||||||
params.completed = undefined;
|
|
||||||
} else {
|
|
||||||
params.completed = false;
|
|
||||||
}
|
|
||||||
if (importantOnly) params.important = true;
|
|
||||||
if (sentFrom) params.sentFrom = sentFrom;
|
if (sentFrom) params.sentFrom = sentFrom;
|
||||||
if (sentTo) params.sentTo = sentTo;
|
if (sentTo) params.sentTo = sentTo;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
|
||||||
|
|
||||||
const clinicOptions = useMemo(() => {
|
const clinicOptions = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -228,10 +219,10 @@ export default function TasksPage() {
|
|||||||
className={`${filterSelectClass} min-w-0 flex-1`}
|
className={`${filterSelectClass} min-w-0 flex-1`}
|
||||||
>
|
>
|
||||||
<option value="date">{t('sortDate')}</option>
|
<option value="date">{t('sortDate')}</option>
|
||||||
<option value="status">{t('sortStatus')}</option>
|
|
||||||
<option value="clinic">{t('sortClinic')}</option>
|
<option value="clinic">{t('sortClinic')}</option>
|
||||||
<option value="patient">{t('sortPatient')}</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>
|
||||||
<select
|
<select
|
||||||
value={sortDir}
|
value={sortDir}
|
||||||
@@ -245,30 +236,6 @@ export default function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="surface-card min-h-[280px]">
|
<section className="surface-card min-h-[280px]">
|
||||||
@@ -279,7 +246,7 @@ export default function TasksPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{tasks.map((task, index) => {
|
{tasks.map((task, index) => {
|
||||||
const commentsOpen = expandedCommentsCaseId === task.labCaseId;
|
const commentsOpen = expandedCommentsTaskId === task.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={task.id}>
|
<li key={task.id}>
|
||||||
@@ -343,7 +310,7 @@ export default function TasksPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
|
setExpandedCommentsTaskId(commentsOpen ? null : task.id)
|
||||||
}
|
}
|
||||||
className={`p-1.5 rounded border ${
|
className={`p-1.5 rounded border ${
|
||||||
commentsOpen
|
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. */
|
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||||
scale?: number;
|
scale?: number;
|
||||||
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ interface CaseToothChartPanelProps {
|
|||||||
export function CaseToothChartPanel({
|
export function CaseToothChartPanel({
|
||||||
details,
|
details,
|
||||||
prosthesisRows,
|
prosthesisRows,
|
||||||
scale = 0.5,
|
scale = 1,
|
||||||
|
compact = true,
|
||||||
className = '',
|
className = '',
|
||||||
}: CaseToothChartPanelProps) {
|
}: CaseToothChartPanelProps) {
|
||||||
const selected = useMemo(() => {
|
const selected = useMemo(() => {
|
||||||
@@ -56,7 +58,7 @@ export function CaseToothChartPanel({
|
|||||||
readOnly
|
readOnly
|
||||||
scale={scale}
|
scale={scale}
|
||||||
toothColors={toothColors}
|
toothColors={toothColors}
|
||||||
compact
|
compact={compact}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function LabCaseAttachmentPreview({
|
|||||||
caseId,
|
caseId,
|
||||||
attachment,
|
attachment,
|
||||||
loadBlob,
|
loadBlob,
|
||||||
className = 'aspect-square w-full max-w-[11rem]',
|
className = 'h-full w-full',
|
||||||
}: LabCaseAttachmentPreviewProps) {
|
}: LabCaseAttachmentPreviewProps) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
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 { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
|
||||||
import type { LabCaseComment } from '@/types/cases';
|
import type { LabCaseComment } from '@/types/cases';
|
||||||
|
|
||||||
interface LabCaseCommentsPanelProps {
|
interface LabCaseCommentsPanelProps {
|
||||||
@@ -15,6 +14,13 @@ interface LabCaseCommentsPanelProps {
|
|||||||
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
|
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
|
||||||
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
|
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
|
||||||
onError?: (message: string) => void;
|
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({
|
export function LabCaseCommentsPanel({
|
||||||
@@ -25,6 +31,9 @@ export function LabCaseCommentsPanel({
|
|||||||
onPost,
|
onPost,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onError,
|
onError,
|
||||||
|
deferSubmit = false,
|
||||||
|
composerValue,
|
||||||
|
onComposerValueChange,
|
||||||
}: LabCaseCommentsPanelProps) {
|
}: LabCaseCommentsPanelProps) {
|
||||||
const t = useTranslations('caseComments');
|
const t = useTranslations('caseComments');
|
||||||
const [comments, setComments] = useState<LabCaseComment[]>([]);
|
const [comments, setComments] = useState<LabCaseComment[]>([]);
|
||||||
@@ -51,7 +60,7 @@ export function LabCaseCommentsPanel({
|
|||||||
|
|
||||||
async function handlePost() {
|
async function handlePost() {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed || !canPost) return;
|
if (!trimmed || !canPost || posting) return;
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
try {
|
try {
|
||||||
const created = await onPost(trimmed, visibleToClinic);
|
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) {
|
async function handleToggle(comment: LabCaseComment) {
|
||||||
if (!onToggleVisibility || !canToggleVisibility) return;
|
if (!onToggleVisibility || !canToggleVisibility) return;
|
||||||
try {
|
try {
|
||||||
@@ -112,12 +128,8 @@ export function LabCaseCommentsPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleToggle(comment)}
|
onClick={() => void handleToggle(comment)}
|
||||||
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
|
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
|
||||||
title={
|
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
|
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{comment.visibleToClinic ? (
|
{comment.visibleToClinic ? (
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
@@ -132,33 +144,54 @@ export function LabCaseCommentsPanel({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canPost ? (
|
{canPost && deferSubmit ? (
|
||||||
<div className="space-y-2 border-t border-border pt-2">
|
<div className="border-t border-border pt-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={composerValue ?? ''}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
||||||
/>
|
/>
|
||||||
{canToggleVisibility ? (
|
</div>
|
||||||
<label className="flex items-center gap-2 text-xs text-text-muted cursor-pointer">
|
) : canPost ? (
|
||||||
<input
|
<div className="flex items-end gap-2 border-t border-border pt-2">
|
||||||
type="checkbox"
|
<textarea
|
||||||
checked={visibleToClinic}
|
value={body}
|
||||||
onChange={(e) => setVisibleToClinic(e.target.checked)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
/>
|
onKeyDown={handleComposerKeyDown}
|
||||||
{t('visibleToClinicToggle')}
|
placeholder={t('placeholder')}
|
||||||
</label>
|
rows={2}
|
||||||
) : null}
|
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
||||||
<Button
|
/>
|
||||||
type="button"
|
<div className="flex items-center gap-1 pb-1">
|
||||||
size="sm"
|
{canToggleVisibility ? (
|
||||||
disabled={posting || !body.trim()}
|
<button
|
||||||
onClick={() => void handlePost()}
|
type="button"
|
||||||
>
|
onClick={() => setVisibleToClinic((v) => !v)}
|
||||||
{t('post')}
|
className={`shrink-0 p-2 rounded-md border transition-colors ${
|
||||||
</Button>
|
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}
|
||||||
|
>
|
||||||
|
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
|
import { canEditCases } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
|
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
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 {
|
import {
|
||||||
formatToothList,
|
formatCaseDateTime,
|
||||||
prosthesisTypeBadgeStyle,
|
formatPatientName,
|
||||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
} from '@/components/ui/lab/caseDetailUtils';
|
||||||
import { treatmentsApi } from '@/lib/api/treatments';
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import type { CounterpartItemDto } from '@/lib/api/organization';
|
import type { CounterpartItemDto } from '@/lib/api/organization';
|
||||||
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
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 {
|
interface ConnectionCaseHistoryContentProps {
|
||||||
connection: CounterpartItemDto;
|
connection: CounterpartItemDto;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -90,10 +55,12 @@ export function ConnectionCaseHistoryContent({
|
|||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||||
const [commentCount, setCommentCount] = useState(0);
|
const [commentCount, setCommentCount] = useState(0);
|
||||||
|
|
||||||
const locale = user?.language ?? 'en';
|
const locale = user?.language ?? 'en';
|
||||||
const isClinic = currentOrganization?.type === 'CLINIC';
|
const isClinic = currentOrganization?.type === 'CLINIC';
|
||||||
|
const canEditImportant = !isClinic && canEditCases(currentOrganization);
|
||||||
|
|
||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
@@ -194,33 +161,24 @@ export function ConnectionCaseHistoryContent({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestCaseAttachment = useMemo(() => {
|
async function handleCaseImportantToggle(isImportant: boolean) {
|
||||||
if (!selectedCase?.attachments.length) return null;
|
if (!selectedCaseId || !canEditImportant || !selectedCase) return;
|
||||||
return [...selectedCase.attachments].sort(
|
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
||||||
)[0];
|
|
||||||
}, [selectedCase?.attachments]);
|
|
||||||
|
|
||||||
const caseProsthesisRows = useMemo(() => {
|
const previousCase = selectedCase;
|
||||||
if (!selectedCase) return [];
|
setSelectedCase({ ...selectedCase, isImportant });
|
||||||
if (selectedCase.toothProsthesis.length > 0) {
|
|
||||||
const byCode = new Map<string, string[]>();
|
setUpdatingImportant(true);
|
||||||
for (const row of selectedCase.toothProsthesis) {
|
setError('');
|
||||||
const key = row.prosthesisTypeCode;
|
try {
|
||||||
const teeth = byCode.get(key) ?? [];
|
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
||||||
if (!teeth.includes(row.tooth)) teeth.push(row.tooth);
|
setSelectedCase(response.data);
|
||||||
byCode.set(key, teeth);
|
} catch (error: unknown) {
|
||||||
}
|
setSelectedCase(previousCase);
|
||||||
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
|
showError(formatApiErrorMessage(error, tCases('errorUpdateTask')));
|
||||||
prosthesisTypeCode,
|
} finally {
|
||||||
teeth,
|
setUpdatingImportant(false);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return selectedCase.tasksByTooth.map((g) => ({
|
}
|
||||||
prosthesisTypeCode: g.prosthesisTypeCode,
|
|
||||||
teeth: g.teeth,
|
|
||||||
}));
|
|
||||||
}, [selectedCase]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-xs text-text-muted mt-1">
|
<div className="text-xs text-text-muted mt-1">
|
||||||
{formatDateTime(item.sentAt, locale)}
|
{formatCaseDateTime(item.sentAt, locale)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-1 truncate">
|
<div className="text-xs text-text-muted mt-1 truncate">
|
||||||
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<TaskProgressBar
|
<CaseTaskProgressBar
|
||||||
completed={item.taskProgress.completed}
|
completed={item.taskProgress.completed}
|
||||||
total={item.taskProgress.total}
|
total={item.taskProgress.total}
|
||||||
/>
|
/>
|
||||||
@@ -340,25 +298,20 @@ export function ConnectionCaseHistoryContent({
|
|||||||
) : loadingDetail || !selectedCase ? (
|
) : loadingDetail || !selectedCase ? (
|
||||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<CaseDetailPanel
|
||||||
<header className="space-y-1 border-b border-border pb-3">
|
labCase={selectedCase}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
locale={locale}
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
treatmentLabel={treatmentLabel}
|
||||||
{formatPatientName(selectedCase.patient)}
|
statusOptions={statusOptions}
|
||||||
</h2>
|
loadAttachmentBlob={loadClinicAttachmentBlob}
|
||||||
{isClinic ? (
|
showCommentsButton={isClinic}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
commentCount={commentCount}
|
||||||
<MessageSquare className="h-4 w-4 me-1.5" />
|
onCommentsClick={scrollToComments}
|
||||||
{commentCount > 0
|
canEditImportant={canEditImportant}
|
||||||
? tCases('commentsCount', { count: commentCount })
|
updatingImportant={updatingImportant}
|
||||||
: tCases('showComments')}
|
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||||
</Button>
|
headerMetaLines={
|
||||||
) : null}
|
!isClinic ? (
|
||||||
</div>
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
{tCases('patientMobile')}: {selectedCase.patient.mobile}
|
|
||||||
</p>
|
|
||||||
{!isClinic ? (
|
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{tCases('fromClinic', { name: selectedCase.clinic.name })}
|
{tCases('fromClinic', { name: selectedCase.clinic.name })}
|
||||||
</p>
|
</p>
|
||||||
@@ -366,148 +319,38 @@ export function ConnectionCaseHistoryContent({
|
|||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('caseHistorySentToLab', { name: connection.organizationName })}
|
{t('caseHistorySentToLab', { name: connection.organizationName })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)
|
||||||
<p className="text-sm text-text-muted">
|
}
|
||||||
{tCases('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
commentsSection={
|
||||||
</p>
|
isClinic && selectedCaseId ? (
|
||||||
<div className="pt-1 max-w-xs">
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
<p className="text-sm text-text-muted mb-1">
|
<LabCaseCommentsPanel
|
||||||
{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}
|
caseId={selectedCaseId}
|
||||||
attachment={latestCaseAttachment}
|
canPost
|
||||||
loadBlob={loadClinicAttachmentBlob}
|
canToggleVisibility={false}
|
||||||
|
loadComments={async () => {
|
||||||
|
const r = await organizationApi.listConnectionCaseComments(
|
||||||
|
connection.id,
|
||||||
|
selectedCaseId,
|
||||||
|
);
|
||||||
|
setCommentCount(r.data.length);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onPost={async (body) => {
|
||||||
|
const r = await organizationApi.addConnectionCaseComment(
|
||||||
|
connection.id,
|
||||||
|
selectedCaseId,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
setCommentCount((n) => n + 1);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onError={showError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
) : null}
|
) : 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 ? (
|
|
||||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
|
||||||
<LabCaseCommentsPanel
|
|
||||||
caseId={selectedCaseId}
|
|
||||||
canPost
|
|
||||||
canToggleVisibility={false}
|
|
||||||
loadComments={async () => {
|
|
||||||
const r = await organizationApi.listConnectionCaseComments(
|
|
||||||
connection.id,
|
|
||||||
selectedCaseId,
|
|
||||||
);
|
|
||||||
setCommentCount(r.data.length);
|
|
||||||
return r.data;
|
|
||||||
}}
|
|
||||||
onPost={async (body) => {
|
|
||||||
const r = await organizationApi.addConnectionCaseComment(
|
|
||||||
connection.id,
|
|
||||||
selectedCaseId,
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
setCommentCount((n) => n + 1);
|
|
||||||
return r.data;
|
|
||||||
}}
|
|
||||||
onError={showError}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface LabCasesDispatchPanelProps {
|
|||||||
onRecentOrganizationPick: (orgId: string) => void;
|
onRecentOrganizationPick: (orgId: string) => void;
|
||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
onAddLabCase: () => void;
|
onAddLabCase: () => void;
|
||||||
onSendLabCase: (labCase: LabCaseDraft) => void;
|
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void;
|
||||||
onCommentError?: (message: string) => void;
|
onCommentError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ export function LabCasesDispatchPanel({
|
|||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||||
|
const [pendingComment, setPendingComment] = useState('');
|
||||||
|
|
||||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||||
const filteredOrganizations = (() => {
|
const filteredOrganizations = (() => {
|
||||||
@@ -196,6 +197,11 @@ export function LabCasesDispatchPanel({
|
|||||||
};
|
};
|
||||||
}, [activeLabCase?.destinationOrganizationId]);
|
}, [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.
|
// Hide dispatch when the selected treatment detail is not lab-dependent.
|
||||||
if (!activeDetail || !isLabDependentDetail) {
|
if (!activeDetail || !isLabDependentDetail) {
|
||||||
return null;
|
return null;
|
||||||
@@ -428,6 +434,9 @@ export function LabCasesDispatchPanel({
|
|||||||
caseId={activeLabCase.id}
|
caseId={activeLabCase.id}
|
||||||
canPost={canEdit && !disabled}
|
canPost={canEdit && !disabled}
|
||||||
canToggleVisibility={false}
|
canToggleVisibility={false}
|
||||||
|
deferSubmit
|
||||||
|
composerValue={pendingComment}
|
||||||
|
onComposerValueChange={setPendingComment}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||||
return r.data;
|
return r.data;
|
||||||
@@ -578,7 +587,7 @@ export function LabCasesDispatchPanel({
|
|||||||
!prosthesisComplete
|
!prosthesisComplete
|
||||||
}
|
}
|
||||||
isLoading={sendBusyId === activeLabCase.clientId}
|
isLoading={sendBusyId === activeLabCase.clientId}
|
||||||
onClick={() => onSendLabCase(activeLabCase)}
|
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||||
>
|
>
|
||||||
{t('sendToLab')}
|
{t('sendToLab')}
|
||||||
</Button>
|
</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 {
|
return {
|
||||||
clientId:
|
clientId:
|
||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
treatmentType: 'restoration',
|
treatmentType: defaultTreatmentType ?? 'restoration',
|
||||||
teeth: [],
|
teeth: [],
|
||||||
comment: '',
|
comment: '',
|
||||||
attachmentMetas: [],
|
attachmentMetas: [],
|
||||||
@@ -502,9 +513,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
}, [showError, t]);
|
}, [showError, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAppointment?.patientId) {
|
if (!selectedAppointment?.patientId) {
|
||||||
setHistoryPatientId(selectedAppointment.patientId);
|
setHistoryPatientId(null);
|
||||||
|
setHistory([]);
|
||||||
|
setHistoryLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setHistoryPatientId(selectedAppointment.patientId);
|
||||||
}, [selectedAppointment?.patientId]);
|
}, [selectedAppointment?.patientId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -558,7 +573,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
});
|
});
|
||||||
setSavedSnapshot(serializeDetails(mapped));
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
} else {
|
} else {
|
||||||
const first = newDetail();
|
const first = newDetail(
|
||||||
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||||
|
);
|
||||||
setDetails([first]);
|
setDetails([first]);
|
||||||
setActiveDetailId(first.clientId);
|
setActiveDetailId(first.clientId);
|
||||||
setSavedSnapshot(serializeDetails([first]));
|
setSavedSnapshot(serializeDetails([first]));
|
||||||
@@ -583,7 +600,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
draftHydratingRef.current = false;
|
draftHydratingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [selectedAppointment?.id, workspaceMode, showError, t]);
|
}, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
|
||||||
|
|
||||||
const persistDraft = useCallback(
|
const persistDraft = useCallback(
|
||||||
async (options?: { force?: boolean }) => {
|
async (options?: { force?: boolean }) => {
|
||||||
@@ -905,7 +922,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSendLabCase = useCallback(
|
const handleSendLabCase = useCallback(
|
||||||
async (labCase: LabCaseDraft) => {
|
async (labCase: LabCaseDraft, comment?: string) => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
if (!labCase.destinationOrganizationId) {
|
if (!labCase.destinationOrganizationId) {
|
||||||
showError(t('errorChooseOrg'));
|
showError(t('errorChooseOrg'));
|
||||||
@@ -934,6 +951,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
);
|
);
|
||||||
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
|
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 response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||||
|
|
||||||
const sentDetailClientIds = new Set(labCase.detailClientIds);
|
const sentDetailClientIds = new Set(labCase.detailClientIds);
|
||||||
@@ -1117,7 +1139,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||||
onAddDetail={() => {
|
onAddDetail={() => {
|
||||||
const next = newDetail();
|
const next = newDetail(
|
||||||
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||||
|
);
|
||||||
setDetails((prev) => [...prev, next]);
|
setDetails((prev) => [...prev, next]);
|
||||||
setActiveDetailId(next.clientId);
|
setActiveDetailId(next.clientId);
|
||||||
}}
|
}}
|
||||||
@@ -1151,7 +1175,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
}}
|
}}
|
||||||
sendBusyId={sendBusyId}
|
sendBusyId={sendBusyId}
|
||||||
onAddLabCase={() => void handleAddLabCase()}
|
onAddLabCase={() => void handleAddLabCase()}
|
||||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
|
||||||
onCommentError={showError}
|
onCommentError={showError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { apiClient } from './client';
|
|||||||
import type {
|
import type {
|
||||||
CasesFilterOptions,
|
CasesFilterOptions,
|
||||||
LabCaseDetail,
|
LabCaseDetail,
|
||||||
LabCaseTask,
|
|
||||||
ListLabCasesParams,
|
ListLabCasesParams,
|
||||||
PaginatedLabCases,
|
PaginatedLabCases,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
@@ -25,12 +24,11 @@ export const casesApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
setTaskImportant: async (
|
setCaseImportant: async (
|
||||||
caseId: string,
|
caseId: string,
|
||||||
taskId: string,
|
|
||||||
isImportant: boolean,
|
isImportant: boolean,
|
||||||
): Promise<{ success: boolean; data: LabCaseTask }> => {
|
): Promise<{ success: boolean; data: LabCaseDetail }> => {
|
||||||
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
|
const response = await apiClient.patch(`/cases/${caseId}/important`, { isImportant });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export interface LabCaseTask {
|
|||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
isImportant: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastStatusChangedAt: string | null;
|
lastStatusChangedAt: string | null;
|
||||||
lastStatusChangedBy: LabTaskUser | null;
|
lastStatusChangedBy: LabTaskUser | null;
|
||||||
@@ -77,6 +76,7 @@ export interface LabCaseAttachmentMeta {
|
|||||||
export interface LabCaseDetail {
|
export interface LabCaseDetail {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
|
isImportant: boolean;
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
id: string;
|
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 {
|
export interface ListLabTasksParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user