From b2d40b3e9732331e9a22b7eaa6ca301d2175eed2 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 7 Jul 2026 17:14:31 +0330 Subject: [PATCH] improvement: users can now comment on a case and it's details and have an option to make it visible for clinics too. --- .../migration.sql | 23 +++ backend/prisma/schema.prisma | 1 - backend/src/modules/cases/cases.service.ts | 1 - .../lab-case-comments.service.ts | 93 ++++++++- .../modules/treatments/dto/treatment.dto.ts | 5 - .../treatments/treatments.controller.ts | 37 +++- .../modules/treatments/treatments.module.ts | 3 +- .../modules/treatments/treatments.service.ts | 5 - frontend/messages/en.json | 6 +- frontend/messages/fa.json | 6 +- frontend/messages/nl.json | 6 +- .../app/[locale]/(dashboard)/cases/page.tsx | 80 +++++--- .../app/[locale]/(dashboard)/tasks/page.tsx | 85 ++++----- .../ui/lab/LabCaseCommentsPanel.tsx | 12 +- .../components/ui/lab/labTaskStatusDisplay.ts | 17 ++ .../ConnectionCaseHistoryContent.tsx | 100 +++++----- .../ui/treatment/LabCasesDispatchPanel.tsx | 177 ++++++++---------- .../ui/treatment/TreatmentWorkspace.tsx | 57 ++++-- frontend/src/lib/api/treatments.ts | 16 ++ frontend/src/types/cases.ts | 2 +- frontend/src/types/treatment.ts | 4 - 21 files changed, 467 insertions(+), 269 deletions(-) create mode 100644 backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql create mode 100644 frontend/src/components/ui/lab/labTaskStatusDisplay.ts diff --git a/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql new file mode 100644 index 0000000..52128f9 --- /dev/null +++ b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql @@ -0,0 +1,23 @@ +-- Migrate legacy single-string labComment into per-case comment rows, then drop the column. + +INSERT INTO "lab_case_comments" ( + "id", + "labCaseId", + "authorSide", + "body", + "visibleToClinic", + "createdAt", + "updatedAt" +) +SELECT + gen_random_uuid()::text, + lc."id", + 'CLINIC'::"LabCaseCommentSide", + trim(lc."labComment"), + true, + COALESCE(lc."sentAt", NOW()), + NOW() +FROM "lab_cases" lc +WHERE lc."labComment" IS NOT NULL AND trim(lc."labComment") <> ''; + +ALTER TABLE "lab_cases" DROP COLUMN "labComment"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c317f39..4494397 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -193,7 +193,6 @@ model LabCase { clientKey String? sortOrder Int destinationOrganizationId String? - labComment String? sentAt DateTime? treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index f873ccb..5404942 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -447,7 +447,6 @@ export class CasesService { return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, - labComment: lc.labComment, clinic: lc.treatment.organization, patient: lc.treatment.patient, appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index a1a04c9..e69a68c 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -74,11 +74,11 @@ export class LabCaseCommentsService { return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) }; } - // ---------- Clinic side (connection access is validated by caller) ---------- + // ---------- Clinic side (connection history) ---------- async listForClinic(caseId: string, clinicOrganizationId: string) { - await this.assertClinicOwnsCase(caseId, clinicOrganizationId); - const comments = await this.fetchComments(caseId, { visibleOnly: true }); + await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true }); + const comments = await this.fetchCommentsForClinicViewer(caseId); return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), @@ -91,7 +91,7 @@ export class LabCaseCommentsService { actorUserId: string, dto: CreateLabCaseCommentDto, ) { - await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true }); const created = await this.prisma.labCaseComment.create({ data: { labCaseId: caseId, @@ -99,7 +99,6 @@ export class LabCaseCommentsService { authorOrganizationId: clinicOrganizationId, authorSide: LabCaseCommentSide.CLINIC, body: dto.body.trim(), - // Clinic-authored comments are inherently visible to the clinic. visibleToClinic: true, }, include: commentInclude, @@ -107,8 +106,60 @@ export class LabCaseCommentsService { return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } + // ---------- Clinic side (treatment dispatch — unsent cases allowed) ---------- + + async listForClinicTreatmentCase( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + ) { + await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); + const comments = await this.fetchCommentsForClinicViewer(caseId); + return { + success: true, + data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), + }; + } + + async addForClinicTreatmentCase( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: clinicOrganizationId, + authorSide: LabCaseCommentSide.CLINIC, + body: dto.body.trim(), + visibleToClinic: true, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; + } + + async countForCase(caseId: string) { + const count = await this.prisma.labCaseComment.count({ where: { labCaseId: caseId } }); + return { success: true, data: { count } }; + } + // ---------- Helpers ---------- + private fetchCommentsForClinicViewer(caseId: string) { + return this.prisma.labCaseComment.findMany({ + where: { + labCaseId: caseId, + OR: [{ visibleToClinic: true }, { authorSide: LabCaseCommentSide.CLINIC }], + }, + include: commentInclude, + orderBy: { createdAt: 'asc' }, + }); + } + private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) { return this.prisma.labCaseComment.findMany({ where: { @@ -121,6 +172,7 @@ export class LabCaseCommentsService { } private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) { + const showVisibilityStatus = viewerSide === LabCaseCommentSide.LAB; return { id: comment.id, body: comment.body, @@ -129,10 +181,10 @@ export class LabCaseCommentsService { authorOrganizationName: comment.authorOrganization?.name ?? null, visibleToClinic: comment.visibleToClinic, createdAt: comment.createdAt.toISOString(), - // Only lab viewers can toggle visibility, and only on lab-authored comments. canToggleVisibility: viewerSide === LabCaseCommentSide.LAB && comment.authorSide === LabCaseCommentSide.LAB, + showVisibilityStatus, }; } @@ -167,11 +219,15 @@ export class LabCaseCommentsService { } } - private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) { + private async assertClinicOwnsCase( + caseId: string, + clinicOrganizationId: string, + opts?: { requireSent?: boolean }, + ) { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, - sentAt: { not: null }, + ...(opts?.requireSent ? { sentAt: { not: null } } : {}), treatment: { organizationId: clinicOrganizationId }, }, select: { id: true }, @@ -180,4 +236,25 @@ export class LabCaseCommentsService { throw new NotFoundException('Case not found'); } } + + private async assertClinicTreatmentAccess( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + ) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + const membership = await this.prisma.membership.findFirst({ + where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (membership.isOwner) return; + const names = membership.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to treatment cases'); + } } diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 0b78f62..0e38e8d 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -71,11 +71,6 @@ export class SaveLabCaseDto { @IsUUID() destinationOrganizationId?: string; - @IsOptional() - @IsString() - @MaxLength(5000) - labComment?: string; - @IsArray() @ArrayMinSize(1) @IsUUID(undefined, { each: true }) diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index da02dcb..59c7e54 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -23,6 +23,8 @@ import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, } from './dto/treatment.dto'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; +import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; import { TreatmentsService } from './treatments.service'; @ApiTags('treatments') @@ -30,7 +32,10 @@ import { TreatmentsService } from './treatments.service'; @UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('treatments') export class TreatmentsController { - constructor(private readonly treatmentsService: TreatmentsService) {} + constructor( + private readonly treatmentsService: TreatmentsService, + private readonly commentsService: LabCaseCommentsService, + ) {} @Get('linked-organizations') @ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' }) @@ -194,4 +199,34 @@ export class TreatmentsController { req.user.language, ); } + + @Get('lab-cases/:labCaseId/comments') + @ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' }) + listLabCaseComments( + @Param('labCaseId') labCaseId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.commentsService.listForClinicTreatmentCase( + labCaseId, + organizationId, + req.user.id, + ); + } + + @Post('lab-cases/:labCaseId/comments') + @ApiOperation({ summary: 'Add a comment to a lab case during treatment dispatch' }) + addLabCaseComment( + @Param('labCaseId') labCaseId: string, + @Body() dto: CreateLabCaseCommentDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.commentsService.addForClinicTreatmentCase( + labCaseId, + organizationId, + req.user.id, + dto, + ); + } } diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts index a587c2d..a819eea 100644 --- a/backend/src/modules/treatments/treatments.module.ts +++ b/backend/src/modules/treatments/treatments.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module'; +import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; import { TreatmentsController } from './treatments.controller'; import { TreatmentsService } from './treatments.service'; @Module({ - imports: [ProsthesisCatalogModule], + imports: [ProsthesisCatalogModule, LabCaseCommentsModule], controllers: [TreatmentsController], providers: [TreatmentsService, PrismaService, ClinicOrgGuard], }) diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index e2aed47..1964e1c 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -397,7 +397,6 @@ export class TreatmentsService { clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, - labComment: lc.labComment?.trim() || null, }, }) : await tx.labCase.create({ @@ -406,7 +405,6 @@ export class TreatmentsService { clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, - labComment: lc.labComment?.trim() || null, }, }); @@ -675,7 +673,6 @@ export class TreatmentsService { clientKey: string | null; sortOrder: number; destinationOrganizationId: string | null; - labComment: string | null; sentAt: Date | null; details: Array<{ treatmentDetailId: string; @@ -754,7 +751,6 @@ export class TreatmentsService { clientKey?: string | null; sortOrder?: number; destinationOrganizationId?: string | null; - labComment?: string | null; sentAt?: Date | null; details?: Array<{ treatmentDetailId: string; @@ -775,7 +771,6 @@ export class TreatmentsService { id: lc.id, clientId: lc.clientKey ?? lc.id, destinationOrganizationId: lc.destinationOrganizationId ?? null, - labComment: lc.labComment ?? null, sentAt: lc.sentAt?.toISOString() ?? null, treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [], details: (lc.details ?? []).map((d) => ({ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c1eafb9..5afd92b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -351,7 +351,8 @@ "filterSentTo": "Sent to", "clearFilters": "Clear filters", "patientMobile": "Mobile", - "labComment": "Lab comment", + "showComments": "Comments", + "commentsCount": "Comments ({count})", "prevPage": "Previous", "nextPage": "Next", "pageSummary": "Page {page} of {totalPages} ({total} cases)", @@ -389,6 +390,7 @@ "sortClinic": "Clinic", "sortPatient": "Patient", "sortImportant": "Important", + "sortDirection": "Sort direction", "clearFilters": "Clear filters", "commentsButton": "Comments", "errorLoadList": "Failed to load tasks.", @@ -540,8 +542,6 @@ "prosthesisColTooth": "Tooth", "prosthesisColDetail": "Detail", "prosthesisColType": "Prosthesis type", - "labComment": "Message for the lab", - "labCommentPlaceholder": "Optional instructions for this shipment…", "selectLab": "Destination lab", "selectLabPlaceholder": "Choose a linked lab…", "sendToLab": "Send to lab", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 5c78203..e67a52f 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -351,7 +351,8 @@ "filterSentTo": "ارسال تا", "clearFilters": "پاک کردن فیلترها", "patientMobile": "موبایل", - "labComment": "یادداشت آزمایشگاه", + "showComments": "نظرات", + "commentsCount": "نظرات ({count})", "prevPage": "قبلی", "nextPage": "بعدی", "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", @@ -389,6 +390,7 @@ "sortClinic": "کلینیک", "sortPatient": "بیمار", "sortImportant": "مهم", + "sortDirection": "جهت مرتب‌سازی", "clearFilters": "پاک کردن فیلترها", "commentsButton": "نظرات", "errorLoadList": "بارگذاری وظایف ناموفق بود.", @@ -540,8 +542,6 @@ "prosthesisColTooth": "دندان", "prosthesisColDetail": "جزئیات", "prosthesisColType": "نوع پروتز", - "labComment": "پیام برای لابراتوار", - "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…", "selectLab": "لابراتوار مقصد", "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", "sendToLab": "ارسال به لابراتوار", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 6fa45d2..9aaf99e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -351,7 +351,8 @@ "filterSentTo": "Verzonden tot", "clearFilters": "Filters wissen", "patientMobile": "Mobiel", - "labComment": "Labnotitie", + "showComments": "Opmerkingen", + "commentsCount": "Opmerkingen ({count})", "prevPage": "Vorige", "nextPage": "Volgende", "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", @@ -389,6 +390,7 @@ "sortClinic": "Kliniek", "sortPatient": "Patiënt", "sortImportant": "Belangrijk", + "sortDirection": "Sorteerrichting", "clearFilters": "Filters wissen", "commentsButton": "Opmerkingen", "errorLoadList": "Taken laden mislukt.", @@ -540,8 +542,6 @@ "prosthesisColTooth": "Tand", "prosthesisColDetail": "Detail", "prosthesisColType": "Prothesetype", - "labComment": "Bericht voor het lab", - "labCommentPlaceholder": "Optionele instructies voor deze zending…", "selectLab": "Bestemmingslab", "selectLabPlaceholder": "Kies een gekoppeld lab…", "sendToLab": "Versturen naar lab", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index c97b264..ad13b3f 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -3,13 +3,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; import { ToastStack } from '@/components/ui/shared/Toast'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; -import { canEditCases } from '@/components/shared/permissions'; -import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; +import { canEditCases, canEditTasks } from '@/components/shared/permissions'; +import { Badge } from '@/components/ui/shared/Badge'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay'; import { casesApi } from '@/lib/api/cases'; +import { tasksApi } from '@/lib/api/tasks'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; import { Button } from '@/components/ui/shared/Button'; @@ -30,17 +34,6 @@ import { const PAGE_SIZE = 20; -function taskStatusVariant(status: LabTaskStatus): BadgeVariant { - switch (status) { - case 'COMPLETED': - return 'success'; - case 'IN_PROGRESS': - return 'default'; - default: - return 'warning'; - } -} - function formatPatientName(patient: { firstName: string; lastName: string }) { return `${patient.firstName} ${patient.lastName}`.trim(); } @@ -105,8 +98,10 @@ export default function CasesPage() { const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [commentCount, setCommentCount] = useState(0); const canEdit = canEditCases(currentOrganization); + const canEditComments = canEditTasks(currentOrganization); const locale = user?.language ?? 'en'; const treatmentLabel = useCallback( @@ -200,12 +195,21 @@ export default function CasesPage() { useEffect(() => { if (selectedCaseId) { void loadDetail(selectedCaseId); + void tasksApi + .listComments(selectedCaseId) + .then((r) => setCommentCount(r.data.length)) + .catch(() => setCommentCount(0)); } else { setSelectedCase(null); + setCommentCount(0); } // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes }, [selectedCaseId]); + function scrollToComments() { + document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); + } + function clearFilters() { setSearch(''); setClinicId(''); @@ -408,9 +412,17 @@ export default function CasesPage() { ) : (
-

- {formatPatientName(selectedCase.patient)} -

+
+

+ {formatPatientName(selectedCase.patient)} +

+ +

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

@@ -432,12 +444,6 @@ export default function CasesPage() { total={selectedCase.taskProgress.total} />
- {selectedCase.labComment ? ( -

- {t('labComment')}:{' '} - {selectedCase.labComment} -

- ) : null} {selectedCase.details.length > 0 && ( @@ -493,7 +499,7 @@ export default function CasesPage() { {task.stepOrder}. {task.stepLabel} - + {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} @@ -530,6 +536,34 @@ export default function CasesPage() { )) )} + + {selectedCaseId ? ( +
+ { + const r = await tasksApi.listComments(selectedCaseId); + setCommentCount(r.data.length); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(selectedCaseId, { + body, + visibleToClinic, + }); + setCommentCount((n) => n + 1); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={toast.showError} + /> +
+ ) : null} )} diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 1a9a7e4..8b2837e 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { MessageSquare } from 'lucide-react'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + labTaskStatusSelectClass, + labTaskStatusVariant, +} from '@/components/ui/lab/labTaskStatusDisplay'; import { formatToothList, prosthesisTypeBadgeStyle, @@ -19,8 +22,6 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { tasksApi } from '@/lib/api/tasks'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; import type { LabTaskListItem, LabTaskStatus, @@ -28,14 +29,9 @@ import type { PaginatedLabTasks, TaskSortField, } from '@/types/cases'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 50; -function taskStatusVariant(status: LabTaskStatus): BadgeVariant { - return status === 'COMPLETED' ? 'success' : 'default'; -} - function formatPatientName(patient: { firstName: string; lastName: string }) { return `${patient.firstName} ${patient.lastName}`.trim(); } @@ -55,7 +51,6 @@ export default function TasksPage() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null); const [search, setSearch] = useState(''); @@ -127,10 +122,6 @@ export default function TasksPage() { } }, [listParams, showError, setError]); - useEffect(() => { - void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); - }, []); - useEffect(() => { if (!canView) return; const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); @@ -191,7 +182,7 @@ export default function TasksPage() { }} placeholder={t('searchPlaceholder')} /> -
+
-
@@ -303,12 +294,6 @@ export default function TasksPage() { {t('importantBadge')} ) : null} - - {task.prosthesisTypeLabel} -

{t('fromClinic', { name: task.clinic.name })} ·{' '} @@ -336,7 +321,7 @@ export default function TasksPage() { onChange={(e) => void handleStatusUpdate(task.id, e.target.value as LabTaskStatus) } - className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`} + className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`} > {statusOptions.map((opt) => (

diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx index 7e7bccc..cf4c664 100644 --- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx +++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx @@ -97,11 +97,13 @@ export function LabCaseCommentsPanel({ {comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')} {comment.authorName ? ` · ${comment.authorName}` : ''} - {comment.visibleToClinic ? ( - {t('clinicCanSee')} - ) : ( - {t('hiddenFromClinic')} - )} + {comment.showVisibilityStatus !== false ? ( + comment.visibleToClinic ? ( + {t('clinicCanSee')} + ) : ( + {t('hiddenFromClinic')} + ) + ) : null}

{comment.body}

diff --git a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts new file mode 100644 index 0000000..e0b9783 --- /dev/null +++ b/frontend/src/components/ui/lab/labTaskStatusDisplay.ts @@ -0,0 +1,17 @@ +import type { BadgeVariant } from '@/components/ui/shared/Badge'; +import type { LabTaskStatus } from '@/types/cases'; + +export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant { + return status === 'COMPLETED' ? 'success' : 'default'; +} + +export function labTaskStatusSelectClass(status: LabTaskStatus): string { + switch (status) { + case 'COMPLETED': + return 'border-success/60 text-success'; + case 'IN_PROGRESS': + return 'border-primary/60 text-primary'; + default: + return ''; + } +} diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index 1db405e..438d4d5 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -2,17 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { organizationApi } from '@/lib/api/organization'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; -import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { ToastStack } from '@/components/ui/shared/Toast'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay'; import { formatToothList, prosthesisTypeBadgeStyle, @@ -23,17 +25,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 20; -function taskStatusVariant(status: LabTaskStatus): BadgeVariant { - switch (status) { - case 'COMPLETED': - return 'success'; - case 'IN_PROGRESS': - return 'default'; - default: - return 'warning'; - } -} - function formatPatientName(patient: { firstName: string; lastName: string }) { return `${patient.firstName} ${patient.lastName}`.trim(); } @@ -96,6 +87,7 @@ export function ConnectionCaseHistoryContent({ const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); + const [commentCount, setCommentCount] = useState(0); const locale = user?.language ?? 'en'; const isClinic = currentOrganization?.type === 'CLINIC'; @@ -154,11 +146,21 @@ export function ConnectionCaseHistoryContent({ useEffect(() => { if (!selectedCaseId) { setSelectedCase(null); + setCommentCount(0); return; } let cancelled = false; + void organizationApi + .listConnectionCaseComments(connection.id, selectedCaseId) + .then((r) => { + if (!cancelled) setCommentCount(r.data.length); + }) + .catch(() => { + if (!cancelled) setCommentCount(0); + }); + void (async () => { setLoadingDetail(true); setError(''); @@ -180,6 +182,10 @@ export function ConnectionCaseHistoryContent({ }; }, [selectedCaseId, connection.id, showError, setError]); + function scrollToComments() { + document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); + } + return (
@@ -300,9 +306,19 @@ export function ConnectionCaseHistoryContent({ ) : (
-

- {formatPatientName(selectedCase.patient)} -

+
+

+ {formatPatientName(selectedCase.patient)} +

+ {isClinic ? ( + + ) : null} +

{tCases('patientMobile')}: {selectedCase.patient.mobile}

@@ -330,12 +346,6 @@ export function ConnectionCaseHistoryContent({ total={selectedCase.taskProgress.total} />
- {selectedCase.labComment ? ( -

- {tCases('labComment')}:{' '} - {selectedCase.labComment} -

- ) : null} {selectedCase.details.length > 0 && ( @@ -395,7 +405,7 @@ export function ConnectionCaseHistoryContent({ {task.stepOrder}. {task.stepLabel} - + {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} @@ -413,27 +423,31 @@ export function ConnectionCaseHistoryContent({
{isClinic && selectedCaseId ? ( - { - const r = await organizationApi.listConnectionCaseComments( - connection.id, - selectedCaseId, - ); - return r.data; - }} - onPost={async (body) => { - const r = await organizationApi.addConnectionCaseComment( - connection.id, - selectedCaseId, - body, - ); - return r.data; - }} - onError={showError} - /> +
+ { + 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} + /> +
) : null}
)} diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 4b2965a..9ad7965 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -1,21 +1,23 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentsApi } from '@/lib/api/treatments'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; interface LabCasesDispatchPanelProps { details: TreatmentDetailDraft[]; + activeDetailId: string; labCases: LabCaseDraft[]; labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; @@ -32,6 +34,7 @@ interface LabCasesDispatchPanelProps { sendBusyId: string | null; onAddLabCase: () => void; onSendLabCase: (labCase: LabCaseDraft) => void; + onCommentError?: (message: string) => void; } function sentDetailClientIds(labCases: LabCaseDraft[]): Set { @@ -56,25 +59,6 @@ function detailInOtherDraftShipment( ); } -function unsentLabDetails( - details: TreatmentDetailDraft[], - labCases: LabCaseDraft[], - labDependentCodes: Set, -): TreatmentDetailDraft[] { - const sent = sentDetailClientIds(labCases); - return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId)); -} - -function detailsAvailableForNewShipment( - details: TreatmentDetailDraft[], - labCases: LabCaseDraft[], - labDependentCodes: Set, -): TreatmentDetailDraft[] { - return unsentLabDetails(details, labCases, labDependentCodes).filter( - (d) => !detailInOtherDraftShipment(d.clientId, labCases, ''), - ); -} - function selectableDetailsForDraftShipment( details: TreatmentDetailDraft[], labCases: LabCaseDraft[], @@ -93,9 +77,11 @@ function selectableDetailsForDraftShipment( function prosthesisTeethRows( labCase: LabCaseDraft, details: TreatmentDetailDraft[], + scopeDetailClientId?: string, ): Array<{ detailClientId: string; tooth: string; detailNumber: number }> { const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = []; for (const clientId of labCase.detailClientIds) { + if (scopeDetailClientId && clientId !== scopeDetailClientId) continue; const detail = details.find((d) => d.clientId === clientId); if (!detail || detail.treatmentType !== 'prosthesis') continue; const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1; @@ -106,8 +92,12 @@ function prosthesisTeethRows( return rows; } -function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean { - const rows = prosthesisTeethRows(labCase, details); +function isProsthesisMapComplete( + labCase: LabCaseDraft, + details: TreatmentDetailDraft[], + scopeDetailClientId?: string, +): boolean { + const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId); if (rows.length === 0) return true; return rows.every((row) => labCase.toothProsthesis.some( @@ -121,6 +111,7 @@ function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetail export function LabCasesDispatchPanel({ details, + activeDetailId, labCases, labDependentCodes, treatmentCatalog, @@ -137,6 +128,7 @@ export function LabCasesDispatchPanel({ sendBusyId, onAddLabCase, onSendLabCase, + onCommentError, }: LabCasesDispatchPanelProps) { const t = useTranslations('treatment'); const [prosthesisOptions, setProsthesisOptions] = useState([]); @@ -152,27 +144,35 @@ export function LabCasesDispatchPanel({ .map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .filter(Boolean) as LinkedOrganizationOption[]; - const labEligibleDetails = useMemo( - () => details.filter((d) => labDependentCodes.has(d.treatmentType)), - [details, labDependentCodes], + const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null; + const isLabDependentDetail = Boolean( + activeDetail && labDependentCodes.has(activeDetail.treatmentType), ); - const canAddLabShipment = useMemo( - () => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0, - [details, labCases, labDependentCodes], - ); + const labCaseForActiveDetail = + labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null; const activeLabCase = - labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null; + labCaseForActiveDetail ?? + (activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null); + + const detailAlreadyInShipment = Boolean(labCaseForActiveDetail); + const canAddLabShipment = + !detailAlreadyInShipment && + !detailInOtherDraftShipment(activeDetailId, labCases, '') && + !sentDetailClientIds(labCases).has(activeDetailId); + const sent = Boolean(activeLabCase?.sentAt); const activeLabOrgName = activeLabCase?.destinationOrganizationId ? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name : null; - const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : []; + const prosthesisRows = activeLabCase + ? prosthesisTeethRows(activeLabCase, details, activeDetailId) + : []; const prosthesisComplete = activeLabCase - ? isProsthesisMapComplete(activeLabCase, details) + ? isProsthesisMapComplete(activeLabCase, details, activeDetailId) : true; useEffect(() => { @@ -196,6 +196,11 @@ export function LabCasesDispatchPanel({ }; }, [activeLabCase?.destinationOrganizationId]); + // Hide dispatch when the selected treatment detail is not lab-dependent. + if (!activeDetail || !isLabDependentDetail) { + return null; + } + function detailNumber(d: TreatmentDetailDraft) { const idx = details.findIndex((row) => row.clientId === d.clientId); return idx >= 0 ? idx + 1 : 0; @@ -269,22 +274,15 @@ export function LabCasesDispatchPanel({ ); } - if (labEligibleDetails.length === 0) { - return ( -
-

{t('labDispatchTitle')}

-

{t('noLabDetails')}

-
- ); - } - const includedInActiveShipment = activeLabCase - ? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId)) + ? [activeDetail] : []; const pickableForActiveDraft = activeLabCase && !sent - ? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase) + ? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter( + (d) => d.clientId === activeDetailId, + ) : []; return ( @@ -308,45 +306,10 @@ export function LabCasesDispatchPanel({ )} - {labCases.length === 0 ? ( + {!detailAlreadyInShipment ? (

{t('labDispatchEmpty')}

- ) : ( - <> -
- {labCases.map((lc, idx) => { - const sentSummary = formatCaseSentSummary( - lc.sends, - { - organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [], - sentAt: lc.sentAt ?? null, - orgs, - }, - t, - ); - return ( - - ); - })} -
- - {activeLabCase && ( -
+ ) : activeLabCase ? ( +
{sent ? ( <>
@@ -369,13 +332,20 @@ export function LabCasesDispatchPanel({ )}
- {activeLabCase.labComment.trim() ? ( -
-

{t('labComment')}

-

- {activeLabCase.labComment} -

-
+ {activeLabCase.id ? ( + { + const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!); + return r.data; + }} + onPost={async () => { + throw new Error('Read-only'); + }} + onError={onCommentError} + /> ) : null} {activeLabOrgName ? ( @@ -423,17 +393,22 @@ export function LabCasesDispatchPanel({ )}
-