diff --git a/.cursor/rules/lab-notifications.mdc b/.cursor/rules/lab-notifications.mdc index 065781d..6d0489a 100644 --- a/.cursor/rules/lab-notifications.mdc +++ b/.cursor/rules/lab-notifications.mdc @@ -7,7 +7,7 @@ alwaysApply: false # Lab tab badges - **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments; Clinic Treatment = visible lab comments + completions. -- **API:** `GET /notifications/tab-counts`; Tasks/Treatment mark read on tab visit; Cases uses per-case read + `hasUnread` on list cards. + - **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed. - **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`. - **Orgs connections badge** stays on separate `pending-count` endpoint. diff --git a/.cursor/skills/lab-notifications/SKILL.md b/.cursor/skills/lab-notifications/SKILL.md index 6bd84b0..e55315e 100644 --- a/.cursor/skills/lab-notifications/SKILL.md +++ b/.cursor/skills/lab-notifications/SKILL.md @@ -12,7 +12,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/ ## Models - **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` -- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS` | `TREATMENT`) for sidebar badge clearing on tab visit. **Cases tab** uses per-case read instead (see below). +- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit. - **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards ## Tab badge buckets (Option B — split lab counts) @@ -28,8 +28,11 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` ## APIs - `GET /notifications/tab-counts` → `{ cases?, tasks?, treatment? }` — **Cases** count = number of cases with unread Cases-bucket activity (per-case read cursor) -- `POST /notifications/mark-tab-read` `{ tab }` — Tasks + Treatment only (Cases skips tab-level clear) +- `GET /notifications/lab-cases/:labCaseId/activities` — activity feed for a case (clinic-safe lab comments) +- `POST /notifications/mark-tab-read` `{ tab }` — Tasks only (Cases/Treatment skip tab-level clear) - `POST /notifications/mark-case-read` `{ labCaseId }` — opening a case clears that case’s unread dot and updates Cases tab count +- `GET /treatments/patients/:patientId/lab-cases` — patient shipment summaries for Treatment rail + tracker cards +- `GET /treatments/lab-cases/unread` — org-wide unread shipment summaries for Treatment “All updates” scope ## Emit activity from @@ -45,12 +48,11 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event). ## Frontend pattern (same as org connections) - `useTabBadgeCounts()` — fetch on pathname change + `tab-badges-changed` event -- `useMarkTabReadOnVisit()` — Tasks + Treatment pages only (Cases badge clears when opening unread cases) +- `useMarkTabReadOnVisit()` — Tasks page only (Cases/Treatment badges clear when opening unread cases) - `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx) - **Organizations** pending connections still use `usePendingConnectionsCount` (separate pending-state API) ## Out of scope (later steps) - Push / email / websockets -- Activity feed UI (Step 6) - `CASE_AMENDED` emit (Step 7) diff --git a/AGENTS.md b/AGENTS.md index 9913a8c..7154132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ frontend/src/ **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org, includes patient name). +- **Unread semantics**: Treatment tab badge = count of unread cases (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). +- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index de69a95..7a97ec3 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -75,12 +75,9 @@ export class LabCaseActivityService { } if (orgType === 'CLINIC') { - const treatment = await this.countUnreadForTab( + const treatment = await this.countUnreadLabCasesForTreatmentTab( userId, organizationId, - 'CLINIC', - CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, - tabSince(LabCaseTabReadTarget.TREATMENT), ); return { success: true, data: { treatment } }; } @@ -106,15 +103,52 @@ export class LabCaseActivityService { userId: string, organizationId: string, ): Promise { + return this.countUnreadLabCasesForOrg( + userId, + organizationId, + LAB_CASES_TAB_ACTIVITY_TYPES, + { + sentAt: { not: null }, + sends: { some: { organizationId } }, + }, + 'LAB', + ); + } + + /** Clinic Treatment tab — unread sent cases (per-case read cursor, not tab visit). */ + async countUnreadLabCasesForTreatmentTab( + userId: string, + organizationId: string, + ): Promise { + return this.countUnreadLabCasesForOrg( + userId, + organizationId, + CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, + { + sentAt: { not: null }, + treatment: { organizationId }, + }, + 'CLINIC', + ); + } + + private async countUnreadLabCasesForOrg( + userId: string, + organizationId: string, + types: LabCaseActivityType[], + labCaseScope: Prisma.LabCaseWhereInput, + orgType: 'LAB' | 'CLINIC', + ): Promise { + const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType); const grouped = await this.prisma.labCaseActivity.groupBy({ by: ['labCaseId'], where: { - type: { in: LAB_CASES_TAB_ACTIVITY_TYPES }, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId } }, - }, + type: { in: types }, + labCase: labCaseScope, OR: [{ actorUserId: null }, { actorUserId: { not: userId } }], + AND: [ + ...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []), + ], }, _max: { createdAt: true }, }); @@ -175,6 +209,73 @@ export class LabCaseActivityService { return unread; } + async listForLabCase( + userId: string, + organizationId: string, + labCaseId: string, + limit = 50, + ) { + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + include: { type: true }, + }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + + await this.assertCanAccessCase(userId, organizationId, labCaseId); + + const orgType = org.type.name === 'LAB' ? 'LAB' : 'CLINIC'; + const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType); + const activities = await this.prisma.labCaseActivity.findMany({ + where: { + labCaseId, + AND: [ + ...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []), + ], + }, + include: { actorUser: { select: { name: true } } }, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(limit, 1), 100), + }); + + const enriched = await this.enrichActivities(activities); + return { success: true, data: enriched }; + } + + async getLastActivitiesForCases( + labCaseIds: string[], + orgType: 'LAB' | 'CLINIC', + ): Promise>[number]>> { + if (labCaseIds.length === 0) return new Map(); + + const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType); + const activities = await this.prisma.labCaseActivity.findMany({ + where: { + labCaseId: { in: labCaseIds }, + AND: [ + ...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []), + ], + }, + include: { actorUser: { select: { name: true } } }, + orderBy: { createdAt: 'desc' }, + }); + + const latestByCase = new Map(); + for (const activity of activities) { + if (!latestByCase.has(activity.labCaseId)) { + latestByCase.set(activity.labCaseId, activity); + } + } + + const enriched = await this.enrichActivities([...latestByCase.values()]); + const map = new Map(); + for (const item of enriched) { + map.set(item.labCaseId, item); + } + return map; + } + async markCaseRead(userId: string, organizationId: string, labCaseId: string) { await this.assertMembership(userId, organizationId); await this.assertCanAccessCase(userId, organizationId, labCaseId); @@ -190,6 +291,79 @@ export class LabCaseActivityService { return { success: true }; } + private async enrichActivities( + activities: Array<{ + id: string; + labCaseId: string; + type: LabCaseActivityType; + actorUserId: string | null; + payload: Prisma.JsonValue | null; + createdAt: Date; + actorUser: { name: string | null } | null; + }>, + ) { + const commentIds: string[] = []; + const taskIds: string[] = []; + + for (const activity of activities) { + const payload = activity.payload as Record | null; + if ( + activity.type === LabCaseActivityType.CLINIC_COMMENT || + activity.type === LabCaseActivityType.LAB_COMMENT + ) { + const commentId = payload?.commentId; + if (typeof commentId === 'string') commentIds.push(commentId); + } + if (activity.type === LabCaseActivityType.TASK_COMPLETED) { + const taskId = payload?.taskId; + if (typeof taskId === 'string') taskIds.push(taskId); + } + } + + const [comments, tasks] = await Promise.all([ + commentIds.length + ? this.prisma.labCaseComment.findMany({ + where: { id: { in: commentIds } }, + select: { id: true, body: true }, + }) + : Promise.resolve([]), + taskIds.length + ? this.prisma.labCaseTask.findMany({ + where: { id: { in: taskIds } }, + select: { id: true, stepLabel: true }, + }) + : Promise.resolve([]), + ]); + + const commentById = new Map( + comments.map((row) => [row.id, row.body] as const), + ); + const taskById = new Map( + tasks.map((row) => [row.id, row.stepLabel] as const), + ); + + return activities.map((activity) => { + const payload = activity.payload as Record | null; + const commentId = + typeof payload?.commentId === 'string' ? payload.commentId : null; + const taskId = typeof payload?.taskId === 'string' ? payload.taskId : null; + + return { + id: activity.id, + labCaseId: activity.labCaseId, + type: activity.type, + createdAt: activity.createdAt.toISOString(), + actorName: activity.actorUser?.name ?? null, + commentBody: commentId ? (commentById.get(commentId) ?? null) : null, + stepLabel: taskId ? (taskById.get(taskId) ?? null) : null, + visibleToClinic: + activity.type === LabCaseActivityType.LAB_COMMENT + ? payload?.visibleToClinic === true + : undefined, + }; + }); + } + private async countUnreadForTab( userId: string, organizationId: string, diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts index 2198b6e..d040e8f 100644 --- a/backend/src/modules/notifications/notifications.controller.ts +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto'; @@ -34,6 +34,25 @@ export class NotificationsController { return this.labCaseActivityService.markTabRead(req.user.id, organizationId, dto.tab); } + @Get('lab-cases/:labCaseId/activities') + @ApiOperation({ summary: 'Activity feed for a lab case' }) + listLabCaseActivities( + @Param('labCaseId', ParseUUIDPipe) labCaseId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit = 50, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: [] }; + } + return this.labCaseActivityService.listForLabCase( + req.user.id, + organizationId, + labCaseId, + limit, + ); + } + @Post('mark-case-read') @ApiOperation({ summary: 'Mark a lab case as read for the current user' }) markCaseRead( diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 991ca1c..2b6f313 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -46,6 +46,27 @@ export class TreatmentsController { return this.treatmentsService.listLinkedOrganizations(req.user.id, organizationId); } + @Get('lab-cases/unread') + @ApiOperation({ summary: 'Sent lab cases with unread lab activity for the organization (TAB_TREATMENT_READ)' }) + listUnreadLabCases(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listUnreadLabCases(organizationId, req.user.id); + } + + @Get('patients/:patientId/lab-cases') + @ApiOperation({ summary: 'Sent lab cases for a patient with tracker summaries (TAB_TREATMENT_READ)' }) + listPatientLabCases( + @Param('patientId') patientId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listPatientLabCases( + patientId, + organizationId, + req.user.id, + ); + } + @Get('patients/:patientId/history') @ApiOperation({ summary: 'List treatments for a patient (draft and completed, TAB_TREATMENT_READ)' }) listPatientHistory( diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 1556529..904c8ce 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -19,8 +19,10 @@ import { } from './dto/treatment.dto'; import { isLabCaseFullyCompleted, + isLabCaseOverdue, parseDueDateInput, } from '../../common/lab-case-due-date'; +import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { generateTreatmentTitle, @@ -29,6 +31,35 @@ import { import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { hasEffectivePermission } from '../../common/membership-permissions'; +const sentLabCaseInclude = { + treatment: { + select: { + id: true, + appointmentId: true, + treatmentAt: true, + patientId: true, + patient: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + details: { + include: { + detail: { + select: { clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + tasks: { select: { status: true } }, + toothProsthesis: { + select: { tooth: true, prosthesisTypeCode: true }, + }, +} satisfies Prisma.LabCaseInclude; + +type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>; + const treatmentInclude = { details: { orderBy: [{ sortOrder: 'asc' as const }], @@ -158,6 +189,174 @@ export class TreatmentsService { return { success: true, data: items.map((t) => this.mapTreatment(t)) }; } + async listPatientLabCases( + patientId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + await this.ensurePatientExists(patientId); + + const membership = await this.getMembership(actorUserId, organizationId); + const isOwner = membership?.isOwner ?? false; + + const labCases = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + treatment: { + patientId, + organizationId, + ...this.treatmentAccessFilter(isOwner, actorUserId), + }, + }, + include: sentLabCaseInclude, + orderBy: [{ sentAt: 'desc' }], + }); + + const summaries = await this.mapSentLabCaseSummaries( + labCases, + actorUserId, + organizationId, + ); + return { success: true, data: this.sortLabCaseSummaries(summaries) }; + } + + async listUnreadLabCases(organizationId: string, actorUserId: string) { + await this.assertCanReadTreatment(actorUserId, organizationId); + + const membership = await this.getMembership(actorUserId, organizationId); + const isOwner = membership?.isOwner ?? false; + + const labCases = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + treatment: { + organizationId, + ...this.treatmentAccessFilter(isOwner, actorUserId), + }, + }, + include: sentLabCaseInclude, + orderBy: [{ sentAt: 'desc' }], + }); + + const caseIds = labCases.map((row) => row.id); + const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch( + actorUserId, + organizationId, + caseIds, + CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, + 'CLINIC', + ); + + const unreadCases = labCases.filter((row) => unreadCaseIds.has(row.id)); + const summaries = await this.mapSentLabCaseSummaries( + unreadCases, + actorUserId, + organizationId, + unreadCaseIds, + ); + return { success: true, data: this.sortLabCaseSummaries(summaries) }; + } + + private treatmentAccessFilter(isOwner: boolean, actorUserId: string) { + return isOwner + ? {} + : { + OR: [ + { providerUserId: actorUserId }, + { appointment: { is: { providerUserId: actorUserId } } }, + ], + }; + } + + private async mapSentLabCaseSummaries( + labCases: SentLabCaseRow[], + actorUserId: string, + organizationId: string, + unreadCaseIdsOverride?: Set, + ) { + const caseIds = labCases.map((row) => row.id); + const [unreadCaseIds, lastActivities] = await Promise.all([ + unreadCaseIdsOverride ?? + (await this.labCaseActivity.unreadCaseIdsInBatch( + actorUserId, + organizationId, + caseIds, + CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, + 'CLINIC', + )), + this.labCaseActivity.getLastActivitiesForCases(caseIds, 'CLINIC'), + ]); + + return labCases.map((lc) => this.mapSentLabCaseSummary(lc, unreadCaseIds, lastActivities)); + } + + private mapSentLabCaseSummary( + lc: SentLabCaseRow, + unreadCaseIds: Set, + lastActivities: Awaited< + ReturnType + >, + ) { + const detailLink = lc.details[0]; + const detail = detailLink?.detail; + const taskProgress = this.mapTaskProgress(lc.tasks); + const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null; + const detailTeeth = detail ? normalizeTeeth(detail.teeth) : []; + + const prosthesisByCode = new Map(); + for (const row of lc.toothProsthesis ?? []) { + const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? []; + list.push(row.tooth); + prosthesisByCode.set(row.prosthesisTypeCode, list); + } + const prosthesisGroups = [...prosthesisByCode.entries()] + .map(([prosthesisTypeCode, teeth]) => ({ + prosthesisTypeCode, + teeth: [...new Set(teeth)].sort(), + })) + .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); + + const patient = lc.treatment.patient; + + return { + labCaseId: lc.id, + patientId: patient.id, + patientFirstName: patient.firstName, + patientLastName: patient.lastName, + treatmentId: lc.treatment.id, + appointmentId: lc.treatment.appointmentId, + treatmentAt: lc.treatment.treatmentAt.toISOString(), + detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '', + teeth: detailTeeth, + prosthesisGroups, + toothCount: prosthesisGroups.length + ? prosthesisGroups.reduce((sum, group) => sum + group.teeth.length, 0) + : detailTeeth.length, + labOrganizationId: labOrg?.id ?? lc.destinationOrganizationId, + labName: labOrg?.name ?? 'Unknown lab', + sentAt: lc.sentAt?.toISOString() ?? null, + dueDate: lc.dueDate?.toISOString() ?? null, + isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), + taskProgress, + hasUnread: unreadCaseIds.has(lc.id), + lastActivity: lastActivities.get(lc.id) ?? null, + }; + } + + private sortLabCaseSummaries< + T extends { hasUnread: boolean; lastActivity: { createdAt: string } | null; sentAt: string | null }, + >(items: T[]): T[] { + return [...items].sort((a, b) => { + if (a.hasUnread !== b.hasUnread) { + return a.hasUnread ? -1 : 1; + } + const aTime = a.lastActivity?.createdAt ?? a.sentAt ?? ''; + const bTime = b.lastActivity?.createdAt ?? b.sentAt ?? ''; + return bTime.localeCompare(aTime); + }); + } + async getDraftForAppointment( appointmentId: string, organizationId: string, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f8dae15..e310e34 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -683,6 +683,7 @@ "prosthesisColTooth": "Tooth", "prosthesisColDetail": "Detail", "prosthesisColType": "Prosthesis type", + "prosthesisUnassigned": "Unassigned", "selectLab": "Destination lab", "selectLabPlaceholder": "Choose a linked lab…", "sendToLab": "Send to lab", @@ -712,6 +713,33 @@ "labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.", "labCaseCommentsTitle": "Lab case comments", "labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.", + "labShipmentsTitle": "Lab shipments", + "labShipmentsSubtitle": "Sent cases for this patient — open one to follow progress and messages.", + "labShipmentsPatientScope": "Sent cases for {patientName} — open one to follow progress and messages.", + "labShipmentsUpdatesScope": "Cases with new lab activity — open one to review and clear the update.", + "labShipmentsScopePatient": "This patient", + "labShipmentsScopeUpdates": "All updates ({count})", + "labShipmentsOtherPatientsUnread": "{count, plural, one {# update on another patient} other {# updates on other patients}}", + "labShipmentsUpdatesEmpty": "No cases with new lab activity.", + "labShipmentsEmpty": "No lab shipments for this patient yet.", + "labShipmentTeethCount": "{count, plural, one {# tooth} other {# teeth}}", + "historyShowFilters": "Show filters", + "historyHideFilters": "Hide filters", + "labTrackerExpand": "View activity", + "labTrackerCollapse": "Hide activity", + "activityFeedTitle": "Activity", + "activityFeedEmpty": "No activity recorded yet.", + "unreadLabCase": "Unread lab updates", + "overdueBadge": "Overdue", + "activityUnknownActor": "Someone", + "activityUnknownStep": "Task", + "activityCaseSent": "Case sent to lab · {date}", + "activityClinicComment": "{actor}: “{preview}” · {date}", + "activityLabComment": "{actor}: “{preview}” · {date}", + "activityTaskCompleted": "{step} completed by {actor} · {date}", + "activityCaseImportant": "Marked important by {actor} · {date}", + "activityCaseAmended": "Case updated by {actor} · {date}", + "activityGeneric": "Update · {date}", "loadingHistory": "Loading history…", "historyEmpty": "No other treatments recorded for this patient yet.", "historyDetailLabel": "Detail {n} · {type}", @@ -737,6 +765,7 @@ "teethNone": "None selected", "historicalReadonlyNotice": "You are viewing a past treatment (read-only).", "errorNoAppointmentForTreatment": "This treatment has no linked appointment and cannot be opened.", + "errorNoTreatmentForPatient": "This patient has no treatment records yet.", "noCases": "No cases in this treatment.", "noDetails": "No treatment details yet.", "typeLabel": "Type:", @@ -750,7 +779,7 @@ "toothChartTitle": "FDI tooth chart", "toothChartTitleCompact": "Tooth chart", "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.", - "toothChartWholePlan": "Show whole treatment plan", + "toothChartWholePlan": "Full View", "labShipmentAttachments": "Files for the lab", "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", "selectedLabel": "Selected:", @@ -820,12 +849,12 @@ "viewCaseHistory": "View case history", "caseHistoryBackToConnections": "← Back to connections", "caseHistoryTitle": "Case history with {name}", - "caseHistorySubtitleClinic": "Cases you sent to this lab, including lab workflow status for each step.", - "caseHistorySubtitleLab": "Cases received from this clinic, including task status for each step.", - "caseHistoryEmpty": "No cases exchanged with this organization yet.", - "caseHistorySentToLab": "Sent to {name}", - "caseHistoryErrorLoadList": "Failed to load case history.", - "caseHistoryErrorLoadDetail": "Failed to load case details." + "caseHistorySlimClinicBody": "Follow lab cases in Treatment for each patient.", + "caseHistorySlimClinicHint": "Open Treatment, select the patient, and use Lab shipments in the left panel to track cases sent to {name}.", + "caseHistorySlimClinicCta": "Open Treatment", + "caseHistorySlimLabBody": "Production cases for this clinic live on the Cases tab.", + "caseHistorySlimLabHint": "Use Cases to work cases received from {name}. Filter by this clinic if needed.", + "caseHistorySlimLabCta": "Open Cases" }, "settings": { "accountTitle": "Account", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 019b9a9..1541622 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -684,6 +684,7 @@ "prosthesisColTooth": "دندان", "prosthesisColDetail": "جزئیات", "prosthesisColType": "نوع پروتز", + "prosthesisUnassigned": "تخصیص‌داده‌نشده", "selectLab": "لابراتوار مقصد", "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", "sendToLab": "ارسال به لابراتوار", @@ -713,6 +714,33 @@ "labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.", "labCaseCommentsTitle": "نظرات پرونده لابراتوار", "labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.", + "labShipmentsTitle": "ارسال‌های لابراتوار", + "labShipmentsSubtitle": "پرونده‌های ارسال‌شده این بیمار — برای پیگیری پیشرفت و پیام‌ها یکی را باز کنید.", + "labShipmentsPatientScope": "پرونده‌های ارسال‌شده برای {patientName} — برای پیگیری پیشرفت و پیام‌ها یکی را باز کنید.", + "labShipmentsUpdatesScope": "پرونده‌های دارای فعالیت جدید لاب — برای بررسی و پاک کردن به‌روزرسانی یکی را باز کنید.", + "labShipmentsScopePatient": "این بیمار", + "labShipmentsScopeUpdates": "همه به‌روزرسانی‌ها ({count})", + "labShipmentsOtherPatientsUnread": "{count, plural, one {# به‌روزرسانی برای بیمار دیگر} other {# به‌روزرسانی برای بیماران دیگر}}", + "labShipmentsUpdatesEmpty": "پرونده‌ای با فعالیت جدید لاب وجود ندارد.", + "labShipmentsEmpty": "هنوز ارسالی به لابراتوار برای این بیمار ثبت نشده است.", + "labShipmentTeethCount": "{count, plural, one {# دندان} other {# دندان}}", + "historyShowFilters": "نمایش فیلترها", + "historyHideFilters": "پنهان کردن فیلترها", + "labTrackerExpand": "مشاهده فعالیت", + "labTrackerCollapse": "پنهان کردن فعالیت", + "activityFeedTitle": "فعالیت", + "activityFeedEmpty": "هنوز فعالیتی ثبت نشده است.", + "unreadLabCase": "به‌روزرسانی‌های خوانده‌نشده لاب", + "overdueBadge": "عقب‌افتاده", + "activityUnknownActor": "کاربر", + "activityUnknownStep": "وظیفه", + "activityCaseSent": "پرونده به لاب ارسال شد · {date}", + "activityClinicComment": "{actor}: «{preview}» · {date}", + "activityLabComment": "{actor}: «{preview}» · {date}", + "activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}", + "activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}", + "activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}", + "activityGeneric": "به‌روزرسانی · {date}", "loadingHistory": "در حال بارگذاری تاریخچه...", "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.", "historyDetailLabel": "جزئیات {n} · {type}", @@ -736,6 +764,7 @@ "detailPendingLabSend": "این جزئیات لاب هنوز ارسال نشده است.", "historicalReadonlyNotice": "در حال مشاهده یک درمان گذشته (فقط خواندنی) هستید.", "errorNoAppointmentForTreatment": "این درمان نوبت مرتبطی ندارد و قابل باز کردن نیست.", + "errorNoTreatmentForPatient": "هنوز هیچ سابقه درمانی برای این بیمار ثبت نشده است.", "teethLabel": "دندان‌ها:", "teethNone": "هیچکدام انتخاب نشده", "noDetails": "هنوز جزئیات درمانی وجود ندارد.", @@ -751,7 +780,7 @@ "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.", - "toothChartWholePlan": "نمایش کل طرح درمان", + "toothChartWholePlan": "نمایش کامل", "labShipmentAttachments": "فایل‌ها برای لابراتوار", "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", "selectedLabel": "انتخاب شده:", @@ -821,12 +850,12 @@ "viewCaseHistory": "مشاهده تاریخچه پرونده‌ها", "caseHistoryBackToConnections": "← بازگشت به اتصالات", "caseHistoryTitle": "تاریخچه پرونده با {name}", - "caseHistorySubtitleClinic": "پرونده‌هایی که به این لابراتوار ارسال کرده‌اید، شامل وضعیت گردش کار لابراتوار برای هر مرحله.", - "caseHistorySubtitleLab": "پرونده‌های دریافتی از این کلینیک، شامل وضعیت وظایف برای هر مرحله.", - "caseHistoryEmpty": "هنوز پرونده‌ای با این سازمان رد و بدل نشده است.", - "caseHistorySentToLab": "ارسال شده به {name}", - "caseHistoryErrorLoadList": "بارگذاری تاریخچه پرونده ناموفق بود.", - "caseHistoryErrorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود." + "caseHistorySlimClinicBody": "پیگیری پرونده‌های لاب را در درمان هر بیمار انجام دهید.", + "caseHistorySlimClinicHint": "درمان را باز کنید، بیمار را انتخاب کنید و از پنل «ارسال‌های لابراتوار» در سمت چپ، پرونده‌های ارسال‌شده به {name} را دنبال کنید.", + "caseHistorySlimClinicCta": "باز کردن درمان", + "caseHistorySlimLabBody": "پرونده‌های تولید این کلینیک در تب پرونده‌ها هستند.", + "caseHistorySlimLabHint": "از تب پرونده‌ها برای کار روی موارد دریافتی از {name} استفاده کنید. در صورت نیاز بر اساس این کلینیک فیلتر کنید.", + "caseHistorySlimLabCta": "باز کردن پرونده‌ها" }, "settings": { "accountTitle": "حساب کاربری", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 44bfbd7..bb158d5 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -683,6 +683,7 @@ "prosthesisColTooth": "Tand", "prosthesisColDetail": "Detail", "prosthesisColType": "Prothesetype", + "prosthesisUnassigned": "Niet toegewezen", "selectLab": "Bestemmingslab", "selectLabPlaceholder": "Kies een gekoppeld lab…", "sendToLab": "Versturen naar lab", @@ -712,6 +713,33 @@ "labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.", "labCaseCommentsTitle": "Opmerkingen labcase", "labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.", + "labShipmentsTitle": "Labzendingen", + "labShipmentsSubtitle": "Verzonden cases voor deze patiënt — open er een om voortgang en berichten te volgen.", + "labShipmentsPatientScope": "Verzonden cases voor {patientName} — open er een om voortgang en berichten te volgen.", + "labShipmentsUpdatesScope": "Cases met nieuwe labactiviteit — open er een om de update te bekijken en te wissen.", + "labShipmentsScopePatient": "Deze patiënt", + "labShipmentsScopeUpdates": "Alle updates ({count})", + "labShipmentsOtherPatientsUnread": "{count, plural, one {# update bij een andere patiënt} other {# updates bij andere patiënten}}", + "labShipmentsUpdatesEmpty": "Geen cases met nieuwe labactiviteit.", + "labShipmentsEmpty": "Nog geen labzendingen voor deze patiënt.", + "labShipmentTeethCount": "{count, plural, one {# tand} other {# tanden}}", + "historyShowFilters": "Filters tonen", + "historyHideFilters": "Filters verbergen", + "labTrackerExpand": "Activiteit bekijken", + "labTrackerCollapse": "Activiteit verbergen", + "activityFeedTitle": "Activiteit", + "activityFeedEmpty": "Nog geen activiteit geregistreerd.", + "unreadLabCase": "Ongelezen lab-updates", + "overdueBadge": "Te laat", + "activityUnknownActor": "Iemand", + "activityUnknownStep": "Taak", + "activityCaseSent": "Case naar lab verzonden · {date}", + "activityClinicComment": "{actor}: “{preview}” · {date}", + "activityLabComment": "{actor}: “{preview}” · {date}", + "activityTaskCompleted": "{step} voltooid door {actor} · {date}", + "activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}", + "activityCaseAmended": "Case bijgewerkt door {actor} · {date}", + "activityGeneric": "Update · {date}", "loadingHistory": "Geschiedenis laden...", "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.", "historyDetailLabel": "Detail {n} · {type}", @@ -735,6 +763,7 @@ "detailPendingLabSend": "Dit labdetail is nog niet verzonden.", "historicalReadonlyNotice": "U bekijkt een eerdere behandeling (alleen-lezen).", "errorNoAppointmentForTreatment": "Deze behandeling heeft geen gekoppelde afspraak en kan niet worden geopend.", + "errorNoTreatmentForPatient": "Deze patiënt heeft nog geen behandelingsgegevens.", "teethLabel": "Tanden:", "teethNone": "Geen geselecteerd", "noCases": "Geen casussen in deze behandeling.", @@ -750,7 +779,7 @@ "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.", - "toothChartWholePlan": "Hele behandelplan tonen", + "toothChartWholePlan": "Volledig overzicht", "labShipmentAttachments": "Bestanden voor het lab", "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", "selectedLabel": "Geselecteerd:", @@ -820,12 +849,12 @@ "viewCaseHistory": "Casusgeschiedenis bekijken", "caseHistoryBackToConnections": "← Terug naar verbindingen", "caseHistoryTitle": "Casusgeschiedenis met {name}", - "caseHistorySubtitleClinic": "Cases die u naar dit lab hebt gestuurd, inclusief lab-workflowstatus per stap.", - "caseHistorySubtitleLab": "Cases ontvangen van deze kliniek, inclusief taakstatus per stap.", - "caseHistoryEmpty": "Nog geen cases uitgewisseld met deze organisatie.", - "caseHistorySentToLab": "Verzonden naar {name}", - "caseHistoryErrorLoadList": "Casusgeschiedenis laden mislukt.", - "caseHistoryErrorLoadDetail": "Casusdetails laden mislukt." + "caseHistorySlimClinicBody": "Volg labcases in Behandeling per patiënt.", + "caseHistorySlimClinicHint": "Open Behandeling, selecteer de patiënt en gebruik Labzendingen in het linkerpaneel om cases naar {name} te volgen.", + "caseHistorySlimClinicCta": "Behandeling openen", + "caseHistorySlimLabBody": "Productiecases voor deze kliniek staan op het tabblad Cases.", + "caseHistorySlimLabHint": "Gebruik Cases voor ontvangen cases van {name}. Filter desgewenst op deze kliniek.", + "caseHistorySlimLabCta": "Cases openen" }, "settings": { "accountTitle": "Account", diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index a1208a4..3fa349f 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -160,6 +160,10 @@ export function CasesPage() { setSelectedCaseId(caseIdFromUrl); setMobileDetailOpen(true); } + const clinicFromUrl = searchParams.get('clinicOrganizationId'); + if (clinicFromUrl) { + setClinicId(clinicFromUrl); + } }, [searchParams]); useEffect(() => { diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx deleted file mode 100644 index af0de87..0000000 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ /dev/null @@ -1,379 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { canEditCases } from '@/components/shared/permissions'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { organizationApi } from '@/lib/api/organization'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; -import { Button } from '@/components/ui/shared/Button'; -import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - formatCaseDateTime, - formatPatientName, -} from '@/components/lab/caseDetailUtils'; -import { treatmentsApi } from '@/lib/api/treatments'; -import { casesApi } from '@/lib/api/cases'; -import type { CounterpartItemDto } from '@/lib/api/organization'; -import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; - -const PAGE_SIZE = 20; - -interface ConnectionCaseHistoryContentProps { - connection: CounterpartItemDto; - onBack: () => void; -} - -export function ConnectionCaseHistoryContent({ - connection, - onBack, -}: ConnectionCaseHistoryContentProps) { - const t = useTranslations('organizations'); - const tErrors = useTranslations('errors'); - const tCases = useTranslations('cases'); - const tCommon = useTranslations('common'); - const { currentOrganization, user } = useAuth(); - const { showError, setError, messages: toastMessages } = useToast(); - - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const [cases, setCases] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: PAGE_SIZE, - total: 0, - totalPages: 1, - }); - const [selectedCaseId, setSelectedCaseId] = useState(null); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - const [selectedCase, setSelectedCase] = useState(null); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingList, setLoadingList] = useState(false); - const [loadingDetail, setLoadingDetail] = useState(false); - const [updatingImportant, setUpdatingImportant] = useState(false); - const [commentCount, setCommentCount] = useState(0); - - const locale = user?.language ?? 'en'; - const isClinic = currentOrganization?.type === 'CLINIC'; - const canEditImportant = !isClinic && canEditCases(currentOrganization); - - const tRef = useRef(t); - tRef.current = t; - - const treatmentLabel = useCallback( - (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), - [treatmentCatalog], - ); - - useEffect(() => { - void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); - }, []); - - const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( - () => [ - { value: 'IN_PROGRESS', label: tCases('statusInProgress') }, - { value: 'COMPLETED', label: tCases('statusCompleted') }, - ], - [tCases], - ); - - useEffect(() => { - let cancelled = false; - - const timeout = setTimeout(() => { - void (async () => { - setLoadingList(true); - setError(''); - try { - const response = await organizationApi.listConnectionCases(connection.id, { - q: search.trim() || undefined, - page, - limit: PAGE_SIZE, - }); - if (cancelled) return; - setCases(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - if (cancelled) return; - showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadList'))); - } finally { - if (!cancelled) setLoadingList(false); - } - })(); - }, search ? 300 : 0); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - }, [search, page, connection.id, showError, setError]); - - 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(''); - try { - const response = await organizationApi.getConnectionCase(connection.id, selectedCaseId); - if (cancelled) return; - setSelectedCase(response.data); - } catch (error: unknown) { - if (cancelled) return; - showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadDetail'))); - setSelectedCase(null); - } finally { - if (!cancelled) setLoadingDetail(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [selectedCaseId, connection.id, showError, setError]); - - useEffect(() => { - if (!selectedCaseId) { - setMobileDetailOpen(false); - } - }, [selectedCaseId]); - - function scrollToComments() { - document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); - } - - const loadClinicAttachmentBlob = useCallback( - (_caseId: string, attachmentId: string) => treatmentsApi.getAttachmentFileBlob(attachmentId), - [], - ); - - async function handleCaseImportantToggle(isImportant: boolean) { - if (!selectedCaseId || !canEditImportant || !selectedCase) return; - - const previousCase = selectedCase; - setSelectedCase({ ...selectedCase, isImportant }); - - setUpdatingImportant(true); - setError(''); - try { - const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); - setSelectedCase(response.data); - } catch (error: unknown) { - setSelectedCase(previousCase); - showError(getUserFacingError(error, tErrors, tCases('errorUpdateTask'))); - } finally { - setUpdatingImportant(false); - } - } - - return ( -
-
- -
- -
-

- {t('caseHistoryTitle', { name: connection.organizationName })} -

-

- {isClinic ? t('caseHistorySubtitleClinic') : t('caseHistorySubtitleLab')} -

-
- -
-
- { - setSearch(value); - setPage(1); - }} - placeholder={tCases('searchPlaceholder')} - /> - -
- {loadingList ? ( -

{tCommon('loading')}

- ) : cases.length === 0 ? ( -

{t('caseHistoryEmpty')}

- ) : ( -
    - {cases.map((item) => { - const isActive = item.id === selectedCaseId; - - return ( -
  • - -
  • - ); - })} -
- )} -
- - {pagination.totalPages > 1 ? ( -
- - - {tCases('pageSummary', { - page: pagination.page, - totalPages: pagination.totalPages, - total: pagination.total, - })} - - -
- ) : null} -
- -
- {mobileDetailOpen && selectedCaseId ? ( - setMobileDetailOpen(false)} /> - ) : null} - {!selectedCaseId ? ( -

{tCases('selectCaseHint')}

- ) : loadingDetail || !selectedCase ? ( -

{tCommon('loading')}

- ) : ( - void handleCaseImportantToggle(checked)} - headerMetaLines={ - !isClinic ? ( -

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

- ) : ( -

- {t('caseHistorySentToLab', { name: connection.organizationName })} -

- ) - } - commentsSection={ - isClinic && selectedCaseId ? ( -
- { - 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/organizations/OrganizationConnectionsMobileList.tsx b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx index 16ed0e3..d0530fc 100644 --- a/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx +++ b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; +import { Check, Trash2, UserPlus, X } from 'lucide-react'; import type { CounterpartItemDto, CounterpartSearchResultDto, @@ -33,7 +33,6 @@ type OrganizationConnectionsMobileListProps = { getInvitationTarget: (row: CounterpartItemDto) => InvitationLinkTarget | null; onCopyInvitation: (row: CounterpartItemDto) => void; onRespond: (rowId: string, action: 'ACCEPT' | 'REJECT') => void; - onViewCaseHistory: (row: CounterpartItemDto) => void; onDeleteConnection: (rowId: string) => void; onSendConnectionRequest: (orgId: string) => void; onToggleInviteForm: () => void; @@ -52,7 +51,6 @@ type OrganizationConnectionsMobileListProps = { sendRequest: string; acceptRequest: string; declineRequest: string; - viewCaseHistory: string; removeConnection: string; statusToday: string; statusFound: string; @@ -79,7 +77,6 @@ export function OrganizationConnectionsMobileList({ getInvitationTarget, onCopyInvitation, onRespond, - onViewCaseHistory, onDeleteConnection, onSendConnectionRequest, onToggleInviteForm, @@ -158,27 +155,16 @@ export function OrganizationConnectionsMobileList({ ) : null} {row.status === 'ACTIVE' ? ( - <> - - - + ) : null} diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx index a4254a2..81c889d 100644 --- a/frontend/src/components/ui/organizations/OrganizationsPage.tsx +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; -import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; +import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; @@ -18,7 +18,6 @@ import { invitationTargetFromConnectionRow } from '@/components/invitations/orga import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { OrganizationConnectionsMobileList } from '@/components/ui/organizations/OrganizationConnectionsMobileList'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; -import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; import { Button } from '@/components/ui/shared/Button'; import { Badge } from '@/components/ui/shared/Badge'; import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; @@ -92,10 +91,6 @@ export function OrganizationsPage() { const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); - const [caseHistoryConnection, setCaseHistoryConnection] = useState( - null, - ); - const { copiedId, copyingInvitationId, @@ -317,15 +312,6 @@ export function OrganizationsPage() { return

{t('loadingOrganization')}

; } - if (caseHistoryConnection) { - return ( - setCaseHistoryConnection(null)} - /> - ); - } - return (
@@ -375,7 +361,6 @@ export function OrganizationsPage() { getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)} onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)} onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)} - onViewCaseHistory={setCaseHistoryConnection} onDeleteConnection={(rowId) => void deleteConnection(rowId)} onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)} onToggleInviteForm={() => setShowInviteForm((v) => !v)} @@ -394,7 +379,6 @@ export function OrganizationsPage() { sendRequest: t('sendRequest'), acceptRequest: t('acceptRequest'), declineRequest: t('declineRequest'), - viewCaseHistory: t('viewCaseHistory'), removeConnection: t('removeConnection'), statusToday: t('statusToday'), statusFound: t('statusFound'), @@ -498,15 +482,6 @@ export function OrganizationsPage() { )} {row.status === 'ACTIVE' && ( <> - + + {expanded ? ( +
+

{t('activityFeedTitle')}

+ +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index df42ac2..c6f4a54 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -9,12 +9,15 @@ import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; +import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentsApi } from '@/lib/api/treatments'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; +import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; +import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; interface LabCasesDispatchPanelProps { details: TreatmentDetailDraft[]; @@ -22,6 +25,11 @@ interface LabCasesDispatchPanelProps { labCases: LabCaseDraft[]; labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; + labCaseSummary?: PatientLabCaseSummary | null; + locale: string; + onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void; + onLabCaseMarkedRead?: (labCaseId: string) => void; + onLabCaseActivityChange?: () => void; activeLabCaseId: string | null; onLabCasesChange: (labCases: LabCaseDraft[]) => void; disabled: boolean; @@ -83,6 +91,11 @@ export function LabCasesDispatchPanel({ labCases, labDependentCodes, treatmentCatalog, + labCaseSummary, + locale, + onLabCaseSummaryChange, + onLabCaseMarkedRead, + onLabCaseActivityChange, activeLabCaseId, onLabCasesChange, disabled, @@ -103,6 +116,7 @@ export function LabCasesDispatchPanel({ const [prosthesisOptions, setProsthesisOptions] = useState([]); const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [pendingComment, setPendingComment] = useState(''); + const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId); const activeLinkedOrganizations = orgs.filter((o) => o.active); const recentOrganizations = recentOrganizationIds @@ -224,6 +238,11 @@ export function LabCasesDispatchPanel({ const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress); const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete); + // Comments/progress belong to the shipment context, even when the treatment is opened from history. + // Do not block commenting just because the treatment editor is read-only. + const canPostComments = canEdit && !caseFullyComplete; + const canShowComments = Boolean(activeLabCase?.id); + const commentsDeferSubmit = Boolean(!sent); async function handleSentDueDateBlur(nextValue: string) { if (!activeLabCase?.id || !sent || !canEditDueDate) return; @@ -252,40 +271,97 @@ export function LabCasesDispatchPanel({ function renderDueDateField() { if (!activeLabCase) return null; const inputValue = toDateInputValue(activeLabCase.dueDate); + const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`; return ( -
) : null} - {activeLabCase.id ? ( - + ) : null} + + {canShowComments && activeLabCase?.id ? ( + { - const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!); - return r.data; - }} - onPost={async (body) => { - const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body }); - return r.data; - }} onError={onCommentError} + onMarkRead={onLabCaseMarkedRead} + onActivityChange={onLabCaseActivityChange} /> ) : null} diff --git a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx index 0ed8432..1cd3731 100644 --- a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx +++ b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx @@ -16,6 +16,7 @@ interface LabDispatchAttentionPanelProps { labDependentCodes: Set; orgs?: LinkedOrganizationOption[]; onGoToDispatch: (item: LabDispatchAttentionItem) => void; + compact?: boolean; } export function LabDispatchAttentionPanel({ @@ -24,6 +25,7 @@ export function LabDispatchAttentionPanel({ labDependentCodes, orgs, onGoToDispatch, + compact = false, }: LabDispatchAttentionPanelProps) { const t = useTranslations('treatment'); @@ -32,16 +34,22 @@ export function LabDispatchAttentionPanel({ } return ( -
-
- -
-

{t('labAttentionTitle')}

-

{t('labAttentionSubtitle')}

+
+ {!compact ? ( +
+ +
+

{t('labAttentionTitle')}

+

{t('labAttentionSubtitle')}

+
-
+ ) : null} -
    +
      {items.map((item) => { const teeth = item.detail.teeth.length ? [...item.detail.teeth].sort().join(', ') @@ -55,7 +63,9 @@ export function LabDispatchAttentionPanel({ return (
    • @@ -92,7 +102,7 @@ export function LabDispatchAttentionPanel({ -
      + + +
      + ); - {loading &&

      {t('loadingHistory')}

      } + const listBlock = ( + <> + {loading &&

      {t('loadingHistory')}

      } {!loading && displayedItems.length === 0 && ( -

      +

      {hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')}

      )} -
      +
      {displayedItems.map((treatment) => { const isSelected = selectedPreviewId === treatment.id; const isCurrentAppointment = @@ -188,6 +194,35 @@ export function PastTreatmentsPanel({ ); })}
      + + ); + + if (compact) { + return ( +
      + + {filtersOpen ? filtersBlock : null} + {listBlock} +
      + ); + } + + return ( +
      +
      +

      + {patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')} +

      +

      {t('historySubtitle')}

      +
      + {filtersBlock} + {listBlock}
      ); } diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 3a72f39..f3206f9 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -13,12 +13,10 @@ import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { - canCommentOnDetailLabCase, isDetailReadyForLabDispatch, isDetailTypeSelected, isLabDependentDetailMissingTeeth, } from '@/components/treatment/treatmentDetailRules'; -import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; interface TreatmentDetailsEditorProps { @@ -35,7 +33,6 @@ interface TreatmentDetailsEditorProps { uploadBusy: boolean; onAddDetail: () => void; onUploadFiles: (files: FileList | null) => void; - onCommentError?: (message: string) => void; } export function TreatmentDetailsEditor({ @@ -52,7 +49,6 @@ export function TreatmentDetailsEditor({ uploadBusy, onAddDetail, onUploadFiles, - onCommentError, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const attachmentInputRef = useRef(null); @@ -74,7 +70,6 @@ export function TreatmentDetailsEditor({ activeDetail, labDependentCodes, ); - const showLabCaseComments = canCommentOnDetailLabCase(activeDetail); return (
      @@ -213,14 +208,6 @@ export function TreatmentDetailsEditor({
      - {showLabCaseComments ? ( - - ) : null} - {canEdit && saveStatus !== 'idle' && (

      void; + items: PatientLabCaseSummary[]; + loading: boolean; + locale: string; + prosthesisCatalog: ProsthesisCatalogEntry[]; + unreadUpdatesCount: number; + otherPatientsUnreadCount: number; + canShowPatientScope: boolean; + selectedLabCaseId?: string | null; + onSelect: (item: PatientLabCaseSummary) => void; + compact?: boolean; +} + +function prosthesisLabel(code: string, catalog: ProsthesisCatalogEntry[]): string { + return catalog.find((entry) => entry.code === code)?.label ?? code; +} + +export function TreatmentLabCasesPanel({ + scope, + onScopeChange, + items, + loading, + locale, + prosthesisCatalog, + unreadUpdatesCount, + otherPatientsUnreadCount, + canShowPatientScope, + selectedLabCaseId, + onSelect, + compact = false, +}: TreatmentLabCasesPanelProps) { + const t = useTranslations('treatment'); + const tCommon = useTranslations('common'); + const showPatientName = scope === 'updates'; + + return ( +

      + {canShowPatientScope || unreadUpdatesCount > 0 ? ( +
      + {canShowPatientScope ? ( + + ) : null} + {unreadUpdatesCount > 0 ? ( + + ) : null} +
      + ) : null} + + {scope === 'patient' && otherPatientsUnreadCount > 0 ? ( + + ) : null} + + {loading ? ( +

      {tCommon('loading')}

      + ) : items.length === 0 ? ( +

      + {scope === 'updates' ? t('labShipmentsUpdatesEmpty') : t('labShipmentsEmpty')} +

      + ) : ( +
        + {items.map((item) => { + const isActive = item.labCaseId === selectedLabCaseId; + + return ( +
      • + +
      • + ); + })} +
      + )} +
      + ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 44595dc..79da58f 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -12,6 +12,7 @@ interface TreatmentPreviewCardProps { labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; + embedded?: boolean; } export function TreatmentPreviewCard({ @@ -20,12 +21,13 @@ export function TreatmentPreviewCard({ labDependentCodes, treatmentCatalog, orgs, + embedded = false, }: TreatmentPreviewCardProps) { const t = useTranslations('treatment'); return ( -
      -

      {heading}

      +
      + {heading ?

      {heading}

      : null} {!treatment ? (

      {t('selectAppointment')}

      diff --git a/frontend/src/components/ui/treatment/TreatmentRailSection.tsx b/frontend/src/components/ui/treatment/TreatmentRailSection.tsx new file mode 100644 index 0000000..19e893e --- /dev/null +++ b/frontend/src/components/ui/treatment/TreatmentRailSection.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface TreatmentRailSectionProps { + title: string; + subtitle?: string; + count?: number; + defaultExpanded?: boolean; + variant?: 'default' | 'attention'; + children: React.ReactNode; +} + +export function TreatmentRailSection({ + title, + subtitle, + count, + defaultExpanded = false, + variant = 'default', + children, +}: TreatmentRailSectionProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + const shellClass = + variant === 'attention' + ? 'surface-card border border-amber-500/35 bg-amber-500/5' + : 'surface-card'; + + return ( +
      + + {expanded ?
      {children}
      : null} +
      + ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index f83c454..e4e7e0e 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -5,6 +5,12 @@ import { useTranslations } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { + TreatmentLabCasesPanel, + type TreatmentLabCasesScope, +} from '@/components/ui/treatment/TreatmentLabCasesPanel'; +import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; @@ -22,7 +28,9 @@ import { } from '@/components/appointments/appointmentTime'; import { appointmentsApi } from '@/lib/api/appointments'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { treatmentsApi } from '@/lib/api/treatments'; +import { notificationsApi } from '@/lib/api/notifications'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { areDetailsPersistable, @@ -35,14 +43,17 @@ import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatc import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions'; import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain'; -import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts'; -import { notificationsApi } from '@/lib/api/notifications'; +import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts'; +import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils'; import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; +import { useAuth } from '@/lib/hooks/useAuth'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useToast } from '@/lib/hooks/useToast'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; import type { Organization } from '@/types/organization'; +import type { Patient } from '@/types/patient'; import type { AppointmentRecord } from '@/types/appointment'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId, LabCaseDraft, @@ -53,6 +64,7 @@ import type { TreatmentAppointment, TreatmentDetailDraft, } from '@/types/treatment'; +import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; type WorkspaceMode = 'live' | 'historical'; @@ -274,11 +286,16 @@ export function TreatmentWorkspace({ }: TreatmentWorkspaceProps) { const t = useTranslations('treatment'); const tErrors = useTranslations('errors'); + const tPatients = useTranslations('patients'); const router = useRouter(); + const { user } = useAuth(); const { showError, showSuccess, messages: toastMessages } = useToast(); + const locale = user?.language ?? 'en'; const canView = canViewTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization); useMarkTabReadOnVisit(); + const tabBadgeCounts = useTabBadgeCounts(); + const initialLabCasesScopeSetRef = useRef(false); const [stripHidden, setStripHidden] = useState(false); const todayStart = useMemo(() => startOfLocalDay(new Date()), []); @@ -292,10 +309,29 @@ export function TreatmentWorkspace({ const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [historyPatientId, setHistoryPatientId] = useState(null); + const [patientLabCases, setPatientLabCases] = useState([]); + const [patientLabCasesLoading, setPatientLabCasesLoading] = useState(false); + const [unreadLabCases, setUnreadLabCases] = useState([]); + const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false); + const [labCasesScope, setLabCasesScope] = useState('patient'); + const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState(null); + const [searchedPatient, setSearchedPatient] = useState | null>(null); + const [patientSearchBusy, setPatientSearchBusy] = useState(false); + + const { + search: patientSearch, + setSearch: setPatientSearch, + patients: patientSearchResults, + loading: patientSearchLoading, + } = usePatientSearchQuery(canView); const [orgs, setOrgs] = useState([]); const [labDependentCodes, setLabDependentCodes] = useState>(new Set()); const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const treatmentDropdownCatalog = useMemo( () => treatmentCatalog.filter((entry) => entry.availableInTreatment), [treatmentCatalog], @@ -357,11 +393,6 @@ export function TreatmentWorkspace({ return match?.id ?? null; }, [labCaseDrafts, activeDetailId]); - useEffect(() => { - if (!activeSentLabCaseId) return; - void notificationsApi.markCaseRead(activeSentLabCaseId).then(() => notifyTabBadgesChanged()); - }, [activeSentLabCaseId]); - const isDirty = useMemo( () => isDetailsDirty(details, savedSnapshot), [details, savedSnapshot], @@ -374,6 +405,55 @@ export function TreatmentWorkspace({ [appointments, selectedAppointmentId], ); + const activePatient = useMemo(() => { + if (selectedAppointment) { + return { + id: selectedAppointment.patientId, + firstName: selectedAppointment.patientFirstName, + lastName: selectedAppointment.patientLastName, + purpose: selectedAppointment.purpose, + }; + } + if (searchedPatient) { + return { + id: searchedPatient.id, + firstName: searchedPatient.firstName, + lastName: searchedPatient.lastName, + purpose: undefined as string | undefined, + }; + } + return null; + }, [selectedAppointment, searchedPatient]); + + const activePatientId = activePatient?.id ?? null; + const activePatientName = activePatient + ? `${activePatient.firstName} ${activePatient.lastName}` + : null; + + const unreadUpdatesCount = unreadLabCases.length; + const otherPatientsUnreadCount = useMemo( + () => unreadLabCases.filter((item) => item.patientId !== activePatientId).length, + [unreadLabCases, activePatientId], + ); + const displayedLabCases = labCasesScope === 'updates' ? unreadLabCases : patientLabCases; + const labCasesListLoading = + labCasesScope === 'updates' + ? unreadLabCasesLoading && unreadLabCases.length === 0 + : patientLabCasesLoading && patientLabCases.length === 0; + const showLabShipmentsSection = Boolean(activePatient) || unreadUpdatesCount > 0; + const labShipmentsSubtitle = + labCasesScope === 'updates' + ? t('labShipmentsUpdatesScope') + : activePatientName + ? t('labShipmentsPatientScope', { patientName: activePatientName }) + : t('labShipmentsSubtitle'); + + useEffect(() => { + if (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) { + setSearchedPatient(null); + } + }, [selectedAppointment, searchedPatient?.id]); + const isViewingPastDay = useMemo( () => compareLocalDayStart(selectedDay, todayStart) < 0, [selectedDay, todayStart], @@ -429,10 +509,6 @@ export function TreatmentWorkspace({ const isBrowsing = selectedPreviewId !== null; - const previewHeading = isBrowsing - ? t('previewBrowsingTitle') - : t('previewCurrentDraft'); - const labAttentionItems = useMemo( () => collectLabDispatchAttention( @@ -557,13 +633,15 @@ export function TreatmentWorkspace({ let cancelled = false; void (async () => { try { - const [orgsResponse, catalogResponse] = await Promise.all([ + const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([ treatmentsApi.listLinkedOrganizations(), treatmentCatalogApi.list(), + prosthesisCatalogApi.list(), ]); if (cancelled) return; setOrgs(orgsResponse.data); setTreatmentCatalog(catalogResponse.data); + setProsthesisCatalog(prosthesisResponse.data); setLabDependentCodes( new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), ); @@ -579,21 +657,85 @@ export function TreatmentWorkspace({ }, [showError, t]); useEffect(() => { - if (!selectedAppointment?.patientId) { + if (!activePatientId) { setHistoryPatientId(null); setHistory([]); setHistoryLoading(false); + setPatientLabCases([]); + setPatientLabCasesLoading(false); + setSelectedRailLabCaseId(null); return; } - const nextPatientId = selectedAppointment.patientId; setHistoryPatientId((prev) => { - if (prev !== nextPatientId) { + if (prev !== activePatientId) { setHistory([]); setHistoryLoading(true); } - return nextPatientId; + return activePatientId; }); - }, [selectedAppointment?.patientId]); + }, [activePatientId]); + + const refreshPatientLabCases = useCallback( + async (patientId: string, options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setPatientLabCasesLoading(true); + try { + const response = await treatmentsApi.listPatientLabCases(patientId); + setPatientLabCases(response.data ?? []); + } catch { + if (!silent) setPatientLabCases([]); + } finally { + if (!silent) setPatientLabCasesLoading(false); + } + }, + [], + ); + + const refreshUnreadLabCases = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setUnreadLabCasesLoading(true); + try { + const response = await treatmentsApi.listUnreadLabCases(); + setUnreadLabCases(response.data ?? []); + } catch { + if (!silent) setUnreadLabCases([]); + } finally { + if (!silent) setUnreadLabCasesLoading(false); + } + }, []); + + const handleLabCaseMarkedRead = useCallback((labCaseId: string) => { + setPatientLabCases((prev) => + prev.map((item) => (item.labCaseId === labCaseId ? { ...item, hasUnread: false } : item)), + ); + setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId)); + }, []); + + useEffect(() => { + void refreshUnreadLabCases(); + }, [refreshUnreadLabCases]); + + useEffect(() => { + if (initialLabCasesScopeSetRef.current) return; + if ((tabBadgeCounts.treatment ?? 0) > 0) { + setLabCasesScope('updates'); + initialLabCasesScopeSetRef.current = true; + return; + } + if (activePatientId) { + setLabCasesScope('patient'); + initialLabCasesScopeSetRef.current = true; + } + }, [tabBadgeCounts.treatment, activePatientId]); + + useEffect(() => { + if (unreadLabCases.length === 0 && labCasesScope === 'updates' && activePatientId) { + setLabCasesScope('patient'); + } + if (unreadLabCases.length > 0 && !activePatientId && labCasesScope === 'patient') { + setLabCasesScope('updates'); + } + }, [unreadLabCases.length, labCasesScope, activePatientId]); useEffect(() => { if (!historyPatientId) return; @@ -604,6 +746,7 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.listPatientHistory(historyPatientId, 50); if (requestId !== historyRequestRef.current) return; setHistory(response.data); + void refreshPatientLabCases(historyPatientId); } catch (error: unknown) { if (requestId !== historyRequestRef.current) return; showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); @@ -613,7 +756,16 @@ export function TreatmentWorkspace({ } } })(); - }, [historyPatientId, showError, t]); + }, [historyPatientId, refreshPatientLabCases, showError, t, tErrors]); + + useEffect(() => { + const onBadgesChanged = () => { + if (historyPatientId) void refreshPatientLabCases(historyPatientId, { silent: true }); + void refreshUnreadLabCases({ silent: true }); + }; + window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged); + return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged); + }, [historyPatientId, refreshPatientLabCases, refreshUnreadLabCases]); useEffect(() => { const appointmentId = selectedAppointment?.id; @@ -731,11 +883,12 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.listPatientHistory(patientId, 50); if (requestId !== historyRequestRef.current) return; setHistory(response.data); + void refreshPatientLabCases(patientId); } catch (error: unknown) { if (requestId !== historyRequestRef.current) return; showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); } - }, [showError, t]); + }, [refreshPatientLabCases, showError, t, tErrors]); const runDraftSave = useCallback(async () => { if (!selectedAppointment || saveInFlightRef.current) { @@ -835,6 +988,7 @@ export function TreatmentWorkspace({ const ok = await flushDraftSave(); if (!ok) return; resetToLiveContext(); + setSearchedPatient(null); setSelectionLocked(true); setSelectedAppointmentId(id); })(); @@ -851,6 +1005,7 @@ export function TreatmentWorkspace({ if (!ok) return; const patientIdToRefresh = historyPatientId; resetToLiveContext(); + setSearchedPatient(null); setSelectionLocked(false); setSelectedDay(startOfLocalDay(day)); if (patientIdToRefresh) { @@ -870,7 +1025,11 @@ export function TreatmentWorkspace({ }, []); const loadTreatmentIntoWorkspace = useCallback( - async (treatment: PastTreatment, focusDetailClientId?: string) => { + async ( + treatment: PastTreatment, + focusDetailClientId?: string, + options?: { scrollToLabPanel?: boolean }, + ) => { if (!treatment.appointmentId) { showError(t('errorNoAppointmentForTreatment')); return false; @@ -882,7 +1041,8 @@ export function TreatmentWorkspace({ const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart); setWorkspaceMode(isHistorical ? 'historical' : 'live'); setSelectedPreviewId(null); - setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt))); + const nextDay = startOfLocalDay(new Date(treatment.treatmentAt)); + setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay)); setSelectionLocked(true); setSelectedAppointmentId(treatment.appointmentId); @@ -902,9 +1062,11 @@ export function TreatmentWorkspace({ if (linked) { setActiveLabCaseId(linked.clientId); } - requestAnimationFrame(() => { - scrollWithinMainScrollContainer(labPanelRef.current); - }); + if (options?.scrollToLabPanel !== false) { + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + } } return true; @@ -912,6 +1074,38 @@ export function TreatmentWorkspace({ [flushDraftSave, hydrateFromTreatment, showError, t, todayStart], ); + const handleSelectSearchedPatient = useCallback( + (patient: Patient) => { + void (async () => { + setPatientSearchBusy(true); + setSearchedPatient({ + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + }); + try { + const response = await treatmentsApi.listPatientHistory(patient.id, 1); + const latest = response.data[0]; + if (!latest) { + showError(t('errorNoTreatmentForPatient')); + setSearchedPatient(null); + return; + } + const ok = await loadTreatmentIntoWorkspace(latest); + if (!ok) { + setSearchedPatient(null); + } + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient'))); + setSearchedPatient(null); + } finally { + setPatientSearchBusy(false); + } + })(); + }, + [loadTreatmentIntoWorkspace, showError, t, tErrors], + ); + const handleLoadIntoWorkspace = useCallback(() => { if (!previewTreatment) return; void loadTreatmentIntoWorkspace(previewTreatment); @@ -943,6 +1137,97 @@ export function TreatmentWorkspace({ [exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], ); + const activeLabCaseSummary = useMemo(() => { + if (activeSentLabCaseId) { + return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null; + } + return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null; + }, [patientLabCases, activeSentLabCaseId, activeDetailId]); + + const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => { + setPatientLabCases((prev) => + prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)), + ); + }, []); + + const handleSelectPatientLabCase = useCallback( + (item: PatientLabCaseSummary) => { + void (async () => { + setSelectedRailLabCaseId(item.labCaseId); + + // Ensure a patient context is established before we potentially clear the last unread update, + // so the rail section doesn't briefly unmount/collapse. + if (item.patientId && item.patientId !== activePatientId) { + setSearchedPatient({ + id: item.patientId, + firstName: item.patientFirstName, + lastName: item.patientLastName, + }); + } + + try { + await notificationsApi.markCaseRead(item.labCaseId); + notifyTabBadgesChanged(); + handleLabCaseMarkedRead(item.labCaseId); + } catch { + // Non-blocking — workspace navigation still proceeds. + } + + let treatment = + history.find((entry) => entry.id === item.treatmentId) ?? + historyPanelItems.find((entry) => entry.id === item.treatmentId) ?? + (selectedAppointment?.id === item.appointmentId ? currentDraftPreview : null); + + if (!treatment && item.patientId) { + try { + const response = await treatmentsApi.listPatientHistory(item.patientId, 50); + treatment = response.data.find((entry) => entry.id === item.treatmentId) ?? null; + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); + return; + } + } + + if (!treatment?.appointmentId) return; + + if ( + selectedAppointmentId === treatment.appointmentId && + workspaceMode === 'live' && + !isBrowsing + ) { + setActiveDetailId(item.detailClientId); + return; + } + + await loadTreatmentIntoWorkspace(treatment, item.detailClientId, { + scrollToLabPanel: false, + }); + })(); + }, + [ + activePatientId, + currentDraftPreview, + handleLabCaseMarkedRead, + history, + historyPanelItems, + isBrowsing, + loadTreatmentIntoWorkspace, + selectedAppointment?.id, + selectedAppointmentId, + showError, + t, + tErrors, + workspaceMode, + ], + ); + + useEffect(() => { + const match = patientLabCases.find((item) => item.detailClientId === activeDetailId); + if (match) { + setSelectedRailLabCaseId(match.labCaseId); + } + }, [activeDetailId, patientLabCases]); + const uploadForDetail = useCallback( async (detailClientId: string, files: FileList | File[]) => { if (!canEditTreatmentForDay || !selectedAppointment) return; @@ -1222,6 +1507,9 @@ export function TreatmentWorkspace({ }); showSuccess(t('successCaseSent')); notifyTabBadgesChanged(); + if (selectedAppointment.patientId) { + void refreshPatientLabCases(selectedAppointment.patientId); + } } catch (error: unknown) { showError(getUserFacingError(error, tErrors, t('errorSendCase'))); } finally { @@ -1233,9 +1521,11 @@ export function TreatmentWorkspace({ selectedAppointment, persistDraft, persistLabCases, + refreshPatientLabCases, showSuccess, showError, t, + tErrors, ], ); @@ -1283,82 +1573,147 @@ export function TreatmentWorkspace({
      - {selectedAppointment ? ( -
      -

      {t('selectedPatient')}

      -

      - {selectedAppointment.patientFirstName} {selectedAppointment.patientLastName} -

      -

      - {t('purposeLabel')}{' '} - - {treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)} - -

      -
      - ) : ( -
      - {apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')} -
      - )} +
      + - - - {isBrowsing && previewTreatment ? ( -
      -

      - {t('browseBanner', { - date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - }), - })} -

      -
      - - + {activePatient ? ( +
      +

      {t('selectedPatient')}

      +

      {activePatientName}

      + {activePatient.purpose ? ( +

      + {t('purposeLabel')}{' '} + + {treatmentTypeLabelFromCatalog(activePatient.purpose, treatmentCatalog)} + +

      + ) : null}
      -
      + ) : ( +

      + {apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')} +

      + )} +
      + + {workspaceMode === 'live' && !isBrowsing && selectedAppointment ? ( + ) : null} - + {isBrowsing && previewTreatment ? ( + <> +
      +

      + {t('browseBanner', { + date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }), + })} +

      +
      + + +
      +
      + +
      + +
      +
      + + ) : null} - + {labAttentionItems.length > 0 ? ( + + + + ) : null} + + {activePatient ? ( + + + + ) : null} + + {showLabShipmentsSection ? ( + + + + ) : null}
      @@ -1411,7 +1766,6 @@ export function TreatmentWorkspace({ setActiveDetailId(next.clientId); }} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} - onCommentError={showError} />
      @@ -1423,6 +1777,15 @@ export function TreatmentWorkspace({ labCases={labCaseDrafts} labDependentCodes={labDependentCodes} treatmentCatalog={treatmentCatalog} + labCaseSummary={activeLabCaseSummary} + locale={locale} + onLabCaseSummaryChange={handleLabCaseSummaryChange} + onLabCaseMarkedRead={handleLabCaseMarkedRead} + onLabCaseActivityChange={() => { + if (historyPatientId) { + void refreshPatientLabCases(historyPatientId, { silent: true }); + } + }} activeLabCaseId={activeLabCaseId} onLabCasesChange={handleLabCasesChange} disabled={!canEditTreatmentForDay} diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 37e43cf..4f4ed3c 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/lib/api/client'; +import type { LabCaseActivityItem } from '@/types/lab-case-activity'; import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils'; export const notificationsApi = { @@ -7,6 +8,16 @@ export const notificationsApi = { return response.data; }, + listLabCaseActivities: async ( + labCaseId: string, + limit = 50, + ): Promise<{ success: boolean; data: LabCaseActivityItem[] }> => { + const response = await apiClient.get(`/notifications/lab-cases/${labCaseId}/activities`, { + params: { limit }, + }); + return response.data; + }, + markTabRead: async (tab: LabCaseTabReadTarget): Promise<{ success: boolean }> => { const response = await apiClient.post('/notifications/mark-tab-read', { tab }); return response.data; diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts index ff027fe..e7bc414 100644 --- a/frontend/src/lib/api/treatments.ts +++ b/frontend/src/lib/api/treatments.ts @@ -24,6 +24,21 @@ export const treatmentsApi = { return response.data; }, + listPatientLabCases: async ( + patientId: string, + ): Promise<{ success: boolean; data: import('@/types/lab-case-activity').PatientLabCaseSummary[] }> => { + const response = await apiClient.get(`/treatments/patients/${patientId}/lab-cases`); + return response.data; + }, + + listUnreadLabCases: async (): Promise<{ + success: boolean; + data: import('@/types/lab-case-activity').PatientLabCaseSummary[]; + }> => { + const response = await apiClient.get('/treatments/lab-cases/unread'); + return response.data; + }, + getDraft: async ( appointmentId: string, ): Promise<{ success: boolean; data: PastTreatment | null }> => { diff --git a/frontend/src/lib/hooks/useTabBadgeCounts.ts b/frontend/src/lib/hooks/useTabBadgeCounts.ts index 03da398..2ad3af7 100644 --- a/frontend/src/lib/hooks/useTabBadgeCounts.ts +++ b/frontend/src/lib/hooks/useTabBadgeCounts.ts @@ -50,8 +50,8 @@ export function useMarkTabReadOnVisit() { useEffect(() => { const tab = tabFromPathname(pathname); - // Cases tab badge clears per opened case (mark-case-read), not on tab visit. - if (!tab || tab === 'CASES' || !currentOrganization?.id) return; + // Cases and Treatment tab badges clear per opened case (mark-case-read), not on tab visit. + if (!tab || tab === 'CASES' || tab === 'TREATMENT' || !currentOrganization?.id) return; void notificationsApi.markTabRead(tab).then(() => { window.dispatchEvent(new Event(tabBadgesChangedEventName())); diff --git a/frontend/src/lib/labCaseActivityLabels.ts b/frontend/src/lib/labCaseActivityLabels.ts new file mode 100644 index 0000000..772c505 --- /dev/null +++ b/frontend/src/lib/labCaseActivityLabels.ts @@ -0,0 +1,56 @@ +import type { LabCaseActivityItem } from '@/types/lab-case-activity'; + +type ActivityLabelTranslator = ( + key: string, + values?: Record, +) => string; + +export function formatLabCaseActivityLine( + activity: LabCaseActivityItem, + t: ActivityLabelTranslator, + locale: string, +): string { + const actor = activity.actorName ?? t('activityUnknownActor'); + const date = new Date(activity.createdAt).toLocaleString(locale, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + switch (activity.type) { + case 'CASE_SENT': + return t('activityCaseSent', { date }); + case 'CLINIC_COMMENT': + return t('activityClinicComment', { + actor, + preview: truncatePreview(activity.commentBody), + date, + }); + case 'LAB_COMMENT': + return t('activityLabComment', { + actor, + preview: truncatePreview(activity.commentBody), + date, + }); + case 'TASK_COMPLETED': + return t('activityTaskCompleted', { + step: activity.stepLabel ?? t('activityUnknownStep'), + actor, + date, + }); + case 'CASE_IMPORTANT': + return t('activityCaseImportant', { actor, date }); + case 'CASE_AMENDED': + return t('activityCaseAmended', { actor, date }); + default: + return t('activityGeneric', { date }); + } +} + +function truncatePreview(text: string | null | undefined, max = 60): string { + const trimmed = text?.trim() ?? ''; + if (!trimmed) return '…'; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max - 1)}…`; +} diff --git a/frontend/src/types/lab-case-activity.ts b/frontend/src/types/lab-case-activity.ts new file mode 100644 index 0000000..6ca2d07 --- /dev/null +++ b/frontend/src/types/lab-case-activity.ts @@ -0,0 +1,45 @@ +export type LabCaseActivityType = + | 'CASE_SENT' + | 'CLINIC_COMMENT' + | 'LAB_COMMENT' + | 'CASE_IMPORTANT' + | 'CASE_AMENDED' + | 'TASK_COMPLETED'; + +export interface LabCaseActivityItem { + id: string; + labCaseId: string; + type: LabCaseActivityType; + createdAt: string; + actorName: string | null; + commentBody?: string | null; + stepLabel?: string | null; + visibleToClinic?: boolean; +} + +export interface PatientLabCaseProsthesisGroup { + prosthesisTypeCode: string; + teeth: string[]; +} + +export interface PatientLabCaseSummary { + labCaseId: string; + patientId: string; + patientFirstName: string; + patientLastName: string; + treatmentId: string; + appointmentId: string | null; + treatmentAt: string; + detailClientId: string; + teeth: string[]; + prosthesisGroups: PatientLabCaseProsthesisGroup[]; + toothCount: number; + labOrganizationId: string | null; + labName: string; + sentAt: string | null; + dueDate: string | null; + isOverdue: boolean; + taskProgress: { completed: number; total: number }; + hasUnread: boolean; + lastActivity: LabCaseActivityItem | null; +}