improvement/ux-overhaul up #61
14
.cursor/rules/lab-notifications.mdc
Normal file
14
.cursor/rules/lab-notifications.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Lab tab badges — activity model, tab-counts API, read cursors
|
||||
globs: backend/src/modules/notifications/**,backend/src/common/lab-case-activity.ts,frontend/src/lib/hooks/useTabBadgeCounts.ts,frontend/src/lib/tabBadgeUtils.ts,frontend/src/lib/api/notifications.ts,frontend/src/components/ui/shared/NavBadgePill.tsx,frontend/src/components/ui/shared/Sidebar.tsx
|
||||
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.
|
||||
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
|
||||
- **Orgs connections badge** stays on separate `pending-count` endpoint.
|
||||
|
||||
Full map: `.cursor/skills/lab-notifications/SKILL.md`
|
||||
56
.cursor/skills/lab-notifications/SKILL.md
Normal file
56
.cursor/skills/lab-notifications/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: dyolink-lab-notifications
|
||||
description: Lab case activity feed + sidebar tab badge counts. Use when changing notifications API, LabCaseActivity, read state, or Sidebar badges for Cases/Tasks/Treatment.
|
||||
---
|
||||
|
||||
# Lab notifications (tab badges)
|
||||
|
||||
Backend: [`backend/src/modules/notifications/`](backend/src/modules/notifications/)
|
||||
Activity types: [`backend/src/common/lab-case-activity.ts`](backend/src/common/lab-case-activity.ts)
|
||||
Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/hooks/useTabBadgeCounts.ts)
|
||||
|
||||
## 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).
|
||||
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
|
||||
|
||||
## Tab badge buckets (Option B — split lab counts)
|
||||
|
||||
| Org | Tab | Activity types |
|
||||
|-----|-----|----------------|
|
||||
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
|
||||
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` |
|
||||
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED` |
|
||||
|
||||
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
|
||||
|
||||
## 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)
|
||||
- `POST /notifications/mark-case-read` `{ labCaseId }` — opening a case clears that case’s unread dot and updates Cases tab count
|
||||
|
||||
## Emit activity from
|
||||
|
||||
| Event | Service |
|
||||
|-------|---------|
|
||||
| First send | `treatments.service` `sendLabCase` → `CASE_SENT` |
|
||||
| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` |
|
||||
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
||||
| Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` |
|
||||
|
||||
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)
|
||||
- `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)
|
||||
@@ -86,6 +86,10 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
|
||||
- **Tasks filters:** filter `<select>`s use the same 44px mobile height as other form controls.
|
||||
- **Treatment lab dispatch:** shipment card `p-3 sm:p-4`; **Send to lab** is `w-full sm:w-auto`.
|
||||
|
||||
## Tab badges
|
||||
|
||||
See `.cursor/skills/lab-notifications/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
|
||||
|
||||
## Permissions
|
||||
|
||||
`TAB_TASKS_READ` / `TAB_TASKS_EDIT`; `LabOrgGuard` on all task routes.
|
||||
|
||||
@@ -45,7 +45,7 @@ frontend/src/
|
||||
- **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 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 — see `.cursor/skills/lab-tasks/SKILL.md`.
|
||||
**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`.
|
||||
|
||||
## Backend layout
|
||||
|
||||
@@ -70,6 +70,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
||||
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
|
||||
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts |
|
||||
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
|
||||
| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
|
||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Lab case activity feed + per-user read cursors for tab badges
|
||||
|
||||
CREATE TYPE "LabCaseActivityType" AS ENUM (
|
||||
'CASE_SENT',
|
||||
'CLINIC_COMMENT',
|
||||
'LAB_COMMENT',
|
||||
'CASE_IMPORTANT',
|
||||
'CASE_AMENDED',
|
||||
'TASK_COMPLETED'
|
||||
);
|
||||
|
||||
CREATE TYPE "LabCaseTabReadTarget" AS ENUM ('CASES', 'TASKS', 'TREATMENT');
|
||||
|
||||
CREATE TABLE "lab_case_activities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"labCaseId" TEXT NOT NULL,
|
||||
"type" "LabCaseActivityType" NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"payload" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "lab_case_activities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "lab_case_user_read_states" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"labCaseId" TEXT NOT NULL,
|
||||
"lastReadAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "lab_case_user_read_states_pkey" PRIMARY KEY ("userId", "organizationId", "labCaseId")
|
||||
);
|
||||
|
||||
CREATE TABLE "lab_case_user_tab_read_states" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"tab" "LabCaseTabReadTarget" NOT NULL,
|
||||
"lastReadAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "lab_case_user_tab_read_states_pkey" PRIMARY KEY ("userId", "organizationId", "tab")
|
||||
);
|
||||
|
||||
ALTER TABLE "lab_case_activities"
|
||||
ADD CONSTRAINT "lab_case_activities_labCaseId_fkey"
|
||||
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "lab_case_activities"
|
||||
ADD CONSTRAINT "lab_case_activities_actorUserId_fkey"
|
||||
FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "lab_case_user_read_states"
|
||||
ADD CONSTRAINT "lab_case_user_read_states_labCaseId_fkey"
|
||||
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "lab_case_activities_labCaseId_createdAt_idx"
|
||||
ON "lab_case_activities"("labCaseId", "createdAt");
|
||||
|
||||
CREATE INDEX "lab_case_activities_type_createdAt_idx"
|
||||
ON "lab_case_activities"("type", "createdAt");
|
||||
|
||||
CREATE INDEX "lab_case_user_read_states_userId_organizationId_idx"
|
||||
ON "lab_case_user_read_states"("userId", "organizationId");
|
||||
@@ -28,6 +28,7 @@ model User {
|
||||
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
|
||||
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
||||
labCaseComments LabCaseComment[]
|
||||
labCaseActivities LabCaseActivity[]
|
||||
phoneVerificationCodes PhoneVerificationCode[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -224,6 +225,8 @@ model LabCase {
|
||||
toothProsthesis LabCaseToothProsthesis[]
|
||||
comments LabCaseComment[]
|
||||
attachments LabCaseAttachment[]
|
||||
activities LabCaseActivity[]
|
||||
userReadStates LabCaseUserReadState[]
|
||||
|
||||
@@index([treatmentId, sortOrder])
|
||||
@@map("lab_cases")
|
||||
@@ -410,6 +413,60 @@ model LabCaseComment {
|
||||
@@map("lab_case_comments")
|
||||
}
|
||||
|
||||
enum LabCaseActivityType {
|
||||
CASE_SENT
|
||||
CLINIC_COMMENT
|
||||
LAB_COMMENT
|
||||
CASE_IMPORTANT
|
||||
CASE_AMENDED
|
||||
TASK_COMPLETED
|
||||
}
|
||||
|
||||
enum LabCaseTabReadTarget {
|
||||
CASES
|
||||
TASKS
|
||||
TREATMENT
|
||||
}
|
||||
|
||||
model LabCaseActivity {
|
||||
id String @id @default(uuid())
|
||||
labCaseId String
|
||||
type LabCaseActivityType
|
||||
actorUserId String?
|
||||
payload Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
actorUser User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([labCaseId, createdAt])
|
||||
@@index([type, createdAt])
|
||||
@@map("lab_case_activities")
|
||||
}
|
||||
|
||||
model LabCaseUserReadState {
|
||||
userId String
|
||||
organizationId String
|
||||
labCaseId String
|
||||
lastReadAt DateTime
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, organizationId, labCaseId])
|
||||
@@index([userId, organizationId])
|
||||
@@map("lab_case_user_read_states")
|
||||
}
|
||||
|
||||
model LabCaseUserTabReadState {
|
||||
userId String
|
||||
organizationId String
|
||||
tab LabCaseTabReadTarget
|
||||
lastReadAt DateTime
|
||||
|
||||
@@id([userId, organizationId, tab])
|
||||
@@map("lab_case_user_tab_read_states")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
import { TodayModule } from './modules/today/today.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -39,6 +40,7 @@ import { TodayModule } from './modules/today/today.module';
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
NotificationsModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
20
backend/src/common/lab-case-activity.ts
Normal file
20
backend/src/common/lab-case-activity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LabCaseActivityType } from '@prisma/client';
|
||||
|
||||
/** Lab Cases tab — new shipments, clinic comments, important flag. */
|
||||
export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||
LabCaseActivityType.CASE_SENT,
|
||||
LabCaseActivityType.CLINIC_COMMENT,
|
||||
LabCaseActivityType.CASE_IMPORTANT,
|
||||
];
|
||||
|
||||
/** Lab Tasks tab — task completions and lab-side comments. */
|
||||
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||
LabCaseActivityType.TASK_COMPLETED,
|
||||
LabCaseActivityType.LAB_COMMENT,
|
||||
];
|
||||
|
||||
/** Clinic Treatment tab — visible lab comments and task progress. */
|
||||
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||
LabCaseActivityType.LAB_COMMENT,
|
||||
LabCaseActivityType.TASK_COMPLETED,
|
||||
];
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module';
|
||||
import { CasesController } from './cases.controller';
|
||||
import { CasesService } from './cases.service';
|
||||
|
||||
@Module({
|
||||
imports: [TreatmentCatalogModule],
|
||||
imports: [TreatmentCatalogModule, NotificationsModule],
|
||||
controllers: [CasesController],
|
||||
providers: [CasesService, PrismaService, LabOrgGuard],
|
||||
exports: [CasesService],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '../../common/lab-case-due-date';
|
||||
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
|
||||
const labCaseListInclude = {
|
||||
treatment: {
|
||||
@@ -92,6 +94,7 @@ export class CasesService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -138,10 +141,21 @@ export class CasesService {
|
||||
this.prisma.labCase.count({ where }),
|
||||
]);
|
||||
|
||||
const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch(
|
||||
actorUserId,
|
||||
labOrganizationId,
|
||||
items.map((lc) => lc.id),
|
||||
LAB_CASES_TAB_ACTIVITY_TYPES,
|
||||
'LAB',
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map((lc) => this.mapLabCaseListItem(lc)),
|
||||
items: items.map((lc) => ({
|
||||
...this.mapLabCaseListItem(lc),
|
||||
hasUnread: unreadCaseIds.has(lc.id),
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
@@ -356,7 +370,7 @@ export class CasesService {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, isImportant: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -368,6 +382,14 @@ export class CasesService {
|
||||
data: { isImportant: dto.isImportant },
|
||||
});
|
||||
|
||||
if (dto.isImportant && !existing.isImportant) {
|
||||
await this.labCaseActivity.record({
|
||||
labCaseId,
|
||||
type: LabCaseActivityType.CASE_IMPORTANT,
|
||||
actorUserId,
|
||||
});
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { LabCaseCommentsController } from './lab-case-comments.controller';
|
||||
import { LabCaseCommentsService } from './lab-case-comments.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [LabCaseCommentsController],
|
||||
providers: [LabCaseCommentsService, PrismaService, LabOrgGuard],
|
||||
exports: [LabCaseCommentsService],
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabCaseCommentSide, Prisma } from '@prisma/client';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
|
||||
const commentInclude = {
|
||||
authorUser: { select: { id: true, name: true } },
|
||||
@@ -19,7 +20,10 @@ type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{
|
||||
|
||||
@Injectable()
|
||||
export class LabCaseCommentsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
) {}
|
||||
|
||||
// ---------- Lab side (TAB_TASKS_EDIT) ----------
|
||||
|
||||
@@ -47,6 +51,15 @@ export class LabCaseCommentsService {
|
||||
},
|
||||
include: commentInclude,
|
||||
});
|
||||
await this.labCaseActivity.record({
|
||||
labCaseId: caseId,
|
||||
type: LabCaseActivityType.LAB_COMMENT,
|
||||
actorUserId,
|
||||
payload: {
|
||||
commentId: created.id,
|
||||
visibleToClinic: created.visibleToClinic,
|
||||
},
|
||||
});
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
|
||||
}
|
||||
|
||||
@@ -104,6 +117,12 @@ export class LabCaseCommentsService {
|
||||
},
|
||||
include: commentInclude,
|
||||
});
|
||||
await this.labCaseActivity.record({
|
||||
labCaseId: caseId,
|
||||
type: LabCaseActivityType.CLINIC_COMMENT,
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
@@ -140,6 +159,12 @@ export class LabCaseCommentsService {
|
||||
},
|
||||
include: commentInclude,
|
||||
});
|
||||
await this.labCaseActivity.record({
|
||||
labCaseId: caseId,
|
||||
type: LabCaseActivityType.CLINIC_COMMENT,
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
|
||||
12
backend/src/modules/notifications/dto/notifications.dto.ts
Normal file
12
backend/src/modules/notifications/dto/notifications.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
import { LabCaseTabReadTarget } from '@prisma/client';
|
||||
|
||||
export class MarkTabReadDto {
|
||||
@IsEnum(LabCaseTabReadTarget)
|
||||
tab!: LabCaseTabReadTarget;
|
||||
}
|
||||
|
||||
export class MarkCaseReadDto {
|
||||
@IsUUID()
|
||||
labCaseId!: string;
|
||||
}
|
||||
331
backend/src/modules/notifications/lab-case-activity.service.ts
Normal file
331
backend/src/modules/notifications/lab-case-activity.service.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
LabCaseActivityType,
|
||||
LabCaseTabReadTarget,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import {
|
||||
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
|
||||
LAB_CASES_TAB_ACTIVITY_TYPES,
|
||||
LAB_TASKS_TAB_ACTIVITY_TYPES,
|
||||
} from '../../common/lab-case-activity';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class LabCaseActivityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(
|
||||
input: {
|
||||
labCaseId: string;
|
||||
type: LabCaseActivityType;
|
||||
actorUserId?: string | null;
|
||||
payload?: Prisma.InputJsonValue;
|
||||
},
|
||||
tx?: TxClient,
|
||||
) {
|
||||
const client = tx ?? this.prisma;
|
||||
await client.labCaseActivity.create({
|
||||
data: {
|
||||
labCaseId: input.labCaseId,
|
||||
type: input.type,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
payload: input.payload ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTabCounts(userId: string, organizationId: string) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
include: { type: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
await this.assertMembership(userId, organizationId);
|
||||
|
||||
const orgType = org.type.name;
|
||||
const tabReads = await this.prisma.labCaseUserTabReadState.findMany({
|
||||
where: { userId, organizationId },
|
||||
});
|
||||
const tabSince = (tab: LabCaseTabReadTarget) =>
|
||||
tabReads.find((row) => row.tab === tab)?.lastReadAt ?? new Date(0);
|
||||
|
||||
if (orgType === 'LAB') {
|
||||
const [cases, tasks] = await Promise.all([
|
||||
this.countUnreadLabCasesForCasesTab(userId, organizationId),
|
||||
this.countUnreadForTab(
|
||||
userId,
|
||||
organizationId,
|
||||
'LAB',
|
||||
LAB_TASKS_TAB_ACTIVITY_TYPES,
|
||||
tabSince(LabCaseTabReadTarget.TASKS),
|
||||
),
|
||||
]);
|
||||
return { success: true, data: { cases, tasks } };
|
||||
}
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
const treatment = await this.countUnreadForTab(
|
||||
userId,
|
||||
organizationId,
|
||||
'CLINIC',
|
||||
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
|
||||
tabSince(LabCaseTabReadTarget.TREATMENT),
|
||||
);
|
||||
return { success: true, data: { treatment } };
|
||||
}
|
||||
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
|
||||
async markTabRead(userId: string, organizationId: string, tab: LabCaseTabReadTarget) {
|
||||
await this.assertMembership(userId, organizationId);
|
||||
const now = new Date();
|
||||
await this.prisma.labCaseUserTabReadState.upsert({
|
||||
where: {
|
||||
userId_organizationId_tab: { userId, organizationId, tab },
|
||||
},
|
||||
create: { userId, organizationId, tab, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Cases with unread activity in the Cases tab bucket (per-case read cursor, not tab visit). */
|
||||
async countUnreadLabCasesForCasesTab(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
): Promise<number> {
|
||||
const grouped = await this.prisma.labCaseActivity.groupBy({
|
||||
by: ['labCaseId'],
|
||||
where: {
|
||||
type: { in: LAB_CASES_TAB_ACTIVITY_TYPES },
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
},
|
||||
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
||||
},
|
||||
_max: { createdAt: true },
|
||||
});
|
||||
|
||||
if (grouped.length === 0) return 0;
|
||||
|
||||
const caseIds = grouped.map((row) => row.labCaseId);
|
||||
const readStates = await this.prisma.labCaseUserReadState.findMany({
|
||||
where: { userId, organizationId, labCaseId: { in: caseIds } },
|
||||
});
|
||||
const readMap = new Map(readStates.map((row) => [row.labCaseId, row.lastReadAt]));
|
||||
|
||||
return grouped.filter((row) => {
|
||||
const since = readMap.get(row.labCaseId) ?? new Date(0);
|
||||
return (row._max.createdAt ?? new Date(0)) > since;
|
||||
}).length;
|
||||
}
|
||||
|
||||
async unreadCaseIdsInBatch(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
labCaseIds: string[],
|
||||
types: LabCaseActivityType[],
|
||||
orgType: 'LAB' | 'CLINIC',
|
||||
): Promise<Set<string>> {
|
||||
if (labCaseIds.length === 0) return new Set();
|
||||
|
||||
const readStates = await this.prisma.labCaseUserReadState.findMany({
|
||||
where: { userId, organizationId, labCaseId: { in: labCaseIds } },
|
||||
});
|
||||
const sinceByCase = new Map(
|
||||
labCaseIds.map((id) => [
|
||||
id,
|
||||
readStates.find((row) => row.labCaseId === id)?.lastReadAt ?? new Date(0),
|
||||
]),
|
||||
);
|
||||
|
||||
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
||||
const activities = await this.prisma.labCaseActivity.findMany({
|
||||
where: {
|
||||
labCaseId: { in: labCaseIds },
|
||||
type: { in: types },
|
||||
AND: [
|
||||
{ OR: [{ actorUserId: null }, { actorUserId: { not: userId } }] },
|
||||
...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []),
|
||||
],
|
||||
},
|
||||
select: { labCaseId: true, createdAt: true },
|
||||
});
|
||||
|
||||
const unread = new Set<string>();
|
||||
for (const activity of activities) {
|
||||
const since = sinceByCase.get(activity.labCaseId)!;
|
||||
if (activity.createdAt > since) {
|
||||
unread.add(activity.labCaseId);
|
||||
}
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
|
||||
async markCaseRead(userId: string, organizationId: string, labCaseId: string) {
|
||||
await this.assertMembership(userId, organizationId);
|
||||
await this.assertCanAccessCase(userId, organizationId, labCaseId);
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.labCaseUserReadState.upsert({
|
||||
where: {
|
||||
userId_organizationId_labCaseId: { userId, organizationId, labCaseId },
|
||||
},
|
||||
create: { userId, organizationId, labCaseId, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async countUnreadForTab(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
orgType: 'LAB' | 'CLINIC',
|
||||
types: LabCaseActivityType[],
|
||||
since: Date,
|
||||
): Promise<number> {
|
||||
const labCaseScope =
|
||||
orgType === 'LAB'
|
||||
? {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
}
|
||||
: {
|
||||
sentAt: { not: null },
|
||||
treatment: { organizationId },
|
||||
};
|
||||
|
||||
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
||||
|
||||
return this.prisma.labCaseActivity.count({
|
||||
where: {
|
||||
type: { in: types },
|
||||
createdAt: { gt: since },
|
||||
labCase: labCaseScope,
|
||||
AND: [
|
||||
{
|
||||
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
||||
},
|
||||
clinicLabCommentFilter,
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private clinicLabCommentFilter(orgType: 'LAB' | 'CLINIC'): Prisma.LabCaseActivityWhereInput {
|
||||
if (orgType !== 'CLINIC') return {};
|
||||
return {
|
||||
OR: [
|
||||
{ type: { not: LabCaseActivityType.LAB_COMMENT } },
|
||||
{
|
||||
type: LabCaseActivityType.LAB_COMMENT,
|
||||
payload: { path: ['visibleToClinic'], equals: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertMembership(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanAccessCase(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
labCaseId: string,
|
||||
) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
include: { type: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where:
|
||||
org.type.name === 'LAB'
|
||||
? {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
}
|
||||
: {
|
||||
id: labCaseId,
|
||||
treatment: { organizationId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
if (org.type.name === 'LAB') {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
organization: { include: { type: true, plan: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!membership ||
|
||||
!(
|
||||
hasEffectivePermission(membership, 'TAB_CASES_READ') ||
|
||||
hasEffectivePermission(membership, 'TAB_TASKS_READ')
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to this case');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
organization: { include: { type: true, plan: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!membership ||
|
||||
!(
|
||||
hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
|
||||
hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to this case');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, Get, Post, 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';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
|
||||
@ApiTags('notifications')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
|
||||
|
||||
@Get('tab-counts')
|
||||
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
|
||||
getTabCounts(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
return this.labCaseActivityService.getTabCounts(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('mark-tab-read')
|
||||
@ApiOperation({ summary: 'Clear sidebar badge for a tab after the user visits it' })
|
||||
markTabRead(
|
||||
@Body() dto: MarkTabReadDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true };
|
||||
}
|
||||
return this.labCaseActivityService.markTabRead(req.user.id, organizationId, dto.tab);
|
||||
}
|
||||
|
||||
@Post('mark-case-read')
|
||||
@ApiOperation({ summary: 'Mark a lab case as read for the current user' })
|
||||
markCaseRead(
|
||||
@Body() dto: MarkCaseReadDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true };
|
||||
}
|
||||
return this.labCaseActivityService.markCaseRead(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
dto.labCaseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/notifications/notifications.module.ts
Normal file
10
backend/src/modules/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationsController],
|
||||
providers: [LabCaseActivityService],
|
||||
exports: [LabCaseActivityService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule],
|
||||
imports: [CatalogModule, NotificationsModule],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
||||
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
|
||||
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
|
||||
const taskListInclude = {
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
@@ -37,6 +38,7 @@ export class TasksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -205,6 +207,18 @@ export class TasksService {
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||
await this.labCaseActivity.record(
|
||||
{
|
||||
labCaseId: task.labCaseId,
|
||||
type: LabCaseActivityType.TASK_COMPLETED,
|
||||
actorUserId,
|
||||
payload: { taskId },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ 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 { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreatmentsController } from './treatments.controller';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@Module({
|
||||
imports: [ProsthesisCatalogModule, LabCaseCommentsModule],
|
||||
imports: [ProsthesisCatalogModule, LabCaseCommentsModule, NotificationsModule],
|
||||
controllers: [TreatmentsController],
|
||||
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isLabCaseFullyCompleted,
|
||||
parseDueDateInput,
|
||||
} from '../../common/lab-case-due-date';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import {
|
||||
generateTreatmentTitle,
|
||||
normalizeTeeth,
|
||||
@@ -83,6 +84,7 @@ export class TreatmentsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -558,6 +560,7 @@ export class TreatmentsService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const isFirstSend = !labCase.sentAt;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.labCaseSend.create({
|
||||
@@ -575,6 +578,17 @@ export class TreatmentsService {
|
||||
}
|
||||
|
||||
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
|
||||
|
||||
if (isFirstSend) {
|
||||
await this.labCaseActivity.record(
|
||||
{
|
||||
labCaseId,
|
||||
type: LabCaseActivityType.CASE_SENT,
|
||||
actorUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
"statusInProgress": "In progress",
|
||||
"statusCompleted": "Completed",
|
||||
"importantLabel": "Important",
|
||||
"unreadCase": "Unread updates",
|
||||
"markCaseImportant": "Mark case as important",
|
||||
"markImportant": "Mark as important",
|
||||
"lastUpdatedBy": "Updated by {name}",
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
"statusInProgress": "در حال انجام",
|
||||
"statusCompleted": "انجام شده",
|
||||
"importantLabel": "مهم",
|
||||
"unreadCase": "بهروزرسانیهای خواندهنشده",
|
||||
"markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
|
||||
"markImportant": "علامتگذاری به عنوان مهم",
|
||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
"statusInProgress": "Bezig",
|
||||
"statusCompleted": "Voltooid",
|
||||
"importantLabel": "Belangrijk",
|
||||
"unreadCase": "Ongelezen updates",
|
||||
"markCaseImportant": "Zaak als belangrijk markeren",
|
||||
"markImportant": "Markeren als belangrijk",
|
||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
formatPatientName,
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
@@ -184,6 +186,14 @@ export function CasesPage() {
|
||||
useEffect(() => {
|
||||
if (selectedCaseId) {
|
||||
void loadDetail(selectedCaseId);
|
||||
void notificationsApi.markCaseRead(selectedCaseId).then(() => {
|
||||
notifyTabBadgesChanged();
|
||||
setCases((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === selectedCaseId ? { ...item, hasUnread: false } : item,
|
||||
),
|
||||
);
|
||||
});
|
||||
void tasksApi
|
||||
.listComments(selectedCaseId)
|
||||
.then((r) => setCommentCount(r.data.length))
|
||||
@@ -244,6 +254,7 @@ export function CasesPage() {
|
||||
item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item,
|
||||
),
|
||||
);
|
||||
notifyTabBadgesChanged();
|
||||
} catch (error: unknown) {
|
||||
setSelectedCase(previousCase);
|
||||
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||
@@ -373,19 +384,27 @@ export function CasesPage() {
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="font-medium text-text-primary">
|
||||
{formatPatientName(item.patient)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<div className="font-medium text-text-primary">
|
||||
{formatPatientName(item.patient)}
|
||||
</div>
|
||||
<LabCaseDueDateBadge
|
||||
dueDate={item.dueDate}
|
||||
locale={locale}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
{item.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<LabCaseDueDateBadge
|
||||
dueDate={item.dueDate}
|
||||
locale={locale}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
{item.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
{item.hasUnread ? (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg"
|
||||
aria-label={t('unreadCase')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
@@ -495,6 +514,7 @@ export function CasesPage() {
|
||||
visibleToClinic,
|
||||
});
|
||||
setCommentCount((n) => n + 1);
|
||||
notifyTabBadgesChanged();
|
||||
return r.data;
|
||||
}}
|
||||
onToggleVisibility={async (commentId, visible) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
labTaskStatusVariant,
|
||||
} from '@/components/lab/labTaskStatusDisplay';
|
||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyleFromCatalog,
|
||||
@@ -228,6 +229,7 @@ export function TaskRow({
|
||||
body,
|
||||
visibleToClinic,
|
||||
});
|
||||
notifyTabBadgesChanged();
|
||||
return r.data;
|
||||
}}
|
||||
onToggleVisibility={async (commentId, visible) => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
isDefaultTasksView,
|
||||
TASK_COMPLETE_EXIT_MS,
|
||||
} from '@/components/lab/tasksViewDefaults';
|
||||
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
@@ -78,6 +80,7 @@ export function TasksPage() {
|
||||
const canView = canViewTasks(currentOrganization);
|
||||
const canEdit = canEditTasks(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
useMarkTabReadOnVisit();
|
||||
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
@@ -273,6 +276,7 @@ export function TasksPage() {
|
||||
} else {
|
||||
await loadTasks();
|
||||
}
|
||||
notifyTabBadgesChanged();
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||
} finally {
|
||||
|
||||
17
frontend/src/components/ui/shared/NavBadgePill.tsx
Normal file
17
frontend/src/components/ui/shared/NavBadgePill.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface NavBadgePillProps {
|
||||
count: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function NavBadgePill({ count, ariaLabel }: NavBadgePillProps) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="min-w-[1.25rem] rounded-full bg-badge-warning-bg px-1.5 py-0.5 text-center text-xs font-medium tabular-nums text-badge-warning-fg border border-badge-warning-border"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
import type { OrgTypeName } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
||||
import { useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||
import { badgeCountForPath } from '@/lib/tabBadgeUtils';
|
||||
import { NavBadgePill } from '@/components/ui/shared/NavBadgePill';
|
||||
import {
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
@@ -48,6 +51,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
const pendingConnectionsCount = usePendingConnectionsCount();
|
||||
const tabBadgeCounts = useTabBadgeCounts();
|
||||
const orgType = currentOrganization?.type;
|
||||
|
||||
const menu = useMemo((): MenuItem[] => {
|
||||
@@ -119,6 +123,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
const isActive = pathname === item.path;
|
||||
const showPendingBadge =
|
||||
item.path === '/organizations' && pendingConnectionsCount > 0;
|
||||
const tabBadgeCount = badgeCountForPath(tabBadgeCounts, item.path);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -134,14 +139,13 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] icon-flat" />
|
||||
<span className="text-sm flex-1">{item.name}</span>
|
||||
{showPendingBadge && (
|
||||
<span
|
||||
className="min-w-[1.25rem] rounded-full bg-badge-warning-bg px-1.5 py-0.5 text-center text-xs font-medium tabular-nums text-badge-warning-fg border border-badge-warning-border"
|
||||
aria-label={`${pendingConnectionsCount} pending connection requests`}
|
||||
>
|
||||
{pendingConnectionsCount}
|
||||
</span>
|
||||
)}
|
||||
{showPendingBadge ? (
|
||||
<NavBadgePill
|
||||
count={pendingConnectionsCount}
|
||||
ariaLabel={`${pendingConnectionsCount} pending connection requests`}
|
||||
/>
|
||||
) : null}
|
||||
<NavBadgePill count={tabBadgeCount} ariaLabel={`${tabBadgeCount} unread updates`} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -35,6 +35,9 @@ 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 { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import type { Organization } from '@/types/organization';
|
||||
@@ -275,6 +278,7 @@ export function TreatmentWorkspace({
|
||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||
const canView = canViewTreatment(currentOrganization);
|
||||
const canEdit = canEditTreatment(currentOrganization);
|
||||
useMarkTabReadOnVisit();
|
||||
|
||||
const [stripHidden, setStripHidden] = useState(false);
|
||||
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
||||
@@ -346,6 +350,18 @@ export function TreatmentWorkspace({
|
||||
[labCaseDrafts],
|
||||
);
|
||||
|
||||
const activeSentLabCaseId = useMemo(() => {
|
||||
const match = labCaseDrafts.find(
|
||||
(lc) => lc.detailClientId === activeDetailId && lc.sentAt && lc.id,
|
||||
);
|
||||
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],
|
||||
@@ -1205,6 +1221,7 @@ export function TreatmentWorkspace({
|
||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
||||
});
|
||||
showSuccess(t('successCaseSent'));
|
||||
notifyTabBadgesChanged();
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
||||
} finally {
|
||||
|
||||
19
frontend/src/lib/api/notifications.ts
Normal file
19
frontend/src/lib/api/notifications.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
|
||||
|
||||
export const notificationsApi = {
|
||||
tabCounts: async (): Promise<{ success: boolean; data: TabBadgeCounts }> => {
|
||||
const response = await apiClient.get('/notifications/tab-counts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markTabRead: async (tab: LabCaseTabReadTarget): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post('/notifications/mark-tab-read', { tab });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markCaseRead: async (labCaseId: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
60
frontend/src/lib/hooks/useTabBadgeCounts.ts
Normal file
60
frontend/src/lib/hooks/useTabBadgeCounts.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { usePathname } from '@/i18n/navigation';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import {
|
||||
tabBadgesChangedEventName,
|
||||
tabFromPathname,
|
||||
type TabBadgeCounts,
|
||||
} from '@/lib/tabBadgeUtils';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
|
||||
const EMPTY_COUNTS: TabBadgeCounts = {};
|
||||
|
||||
export function useTabBadgeCounts(): TabBadgeCounts {
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
const [counts, setCounts] = useState<TabBadgeCounts>(EMPTY_COUNTS);
|
||||
|
||||
const fetchCounts = useCallback(async () => {
|
||||
if (!currentOrganization?.id) {
|
||||
setCounts(EMPTY_COUNTS);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await notificationsApi.tabCounts();
|
||||
setCounts(res.data ?? EMPTY_COUNTS);
|
||||
} catch {
|
||||
setCounts(EMPTY_COUNTS);
|
||||
}
|
||||
}, [currentOrganization?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCounts();
|
||||
}, [fetchCounts, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => void fetchCounts();
|
||||
window.addEventListener(tabBadgesChangedEventName(), onChanged);
|
||||
return () => window.removeEventListener(tabBadgesChangedEventName(), onChanged);
|
||||
}, [fetchCounts]);
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function useMarkTabReadOnVisit() {
|
||||
const pathname = usePathname();
|
||||
const { currentOrganization } = useAuth();
|
||||
|
||||
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;
|
||||
|
||||
void notificationsApi.markTabRead(tab).then(() => {
|
||||
window.dispatchEvent(new Event(tabBadgesChangedEventName()));
|
||||
});
|
||||
}, [pathname, currentOrganization?.id]);
|
||||
}
|
||||
31
frontend/src/lib/tabBadgeUtils.ts
Normal file
31
frontend/src/lib/tabBadgeUtils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type TabBadgeCounts = {
|
||||
cases?: number;
|
||||
tasks?: number;
|
||||
treatment?: number;
|
||||
};
|
||||
|
||||
export type LabCaseTabReadTarget = 'CASES' | 'TASKS' | 'TREATMENT';
|
||||
|
||||
const TAB_BADGES_CHANGED_EVENT = 'tab-badges-changed';
|
||||
|
||||
export function notifyTabBadgesChanged() {
|
||||
window.dispatchEvent(new Event(TAB_BADGES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function tabBadgesChangedEventName() {
|
||||
return TAB_BADGES_CHANGED_EVENT;
|
||||
}
|
||||
|
||||
export function tabFromPathname(pathname: string): LabCaseTabReadTarget | null {
|
||||
if (pathname === '/cases' || pathname.startsWith('/cases/')) return 'CASES';
|
||||
if (pathname === '/tasks' || pathname.startsWith('/tasks/')) return 'TASKS';
|
||||
if (pathname === '/treatment' || pathname.startsWith('/treatment/')) return 'TREATMENT';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function badgeCountForPath(counts: TabBadgeCounts, pathname: string): number {
|
||||
if (pathname === '/cases' || pathname.startsWith('/cases/')) return counts.cases ?? 0;
|
||||
if (pathname === '/tasks' || pathname.startsWith('/tasks/')) return counts.tasks ?? 0;
|
||||
if (pathname === '/treatment' || pathname.startsWith('/treatment/')) return counts.treatment ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface LabCaseListItem {
|
||||
dueDate: string | null;
|
||||
isOverdue: boolean;
|
||||
isImportant: boolean;
|
||||
hasUnread: boolean;
|
||||
clinic: { id: string; name: string };
|
||||
patient: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user