improvement/ux-overhaul up #61

Merged
rameen merged 24 commits from improvement/ux-overhaul into master 2026-07-14 22:46:19 +03:30
33 changed files with 908 additions and 31 deletions
Showing only changes of commit 2ad572f4c8 - Show all commits

View 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`

View 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 cases 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)

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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");

View File

@@ -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"

View File

@@ -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],

View 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,
];

View File

@@ -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],

View File

@@ -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,

View File

@@ -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],

View File

@@ -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) };
}

View 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;
}

View 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');
}
}
}

View File

@@ -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,
);
}
}

View 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 {}

View File

@@ -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],
})

View File

@@ -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;
});

View File

@@ -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],
})

View File

@@ -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({

View File

@@ -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}",

View File

@@ -428,6 +428,7 @@
"statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده",
"importantLabel": "مهم",
"unreadCase": "به‌روزرسانی‌های خوانده‌نشده",
"markCaseImportant": "علامت‌گذاری پرونده به‌عنوان مهم",
"markImportant": "علامت‌گذاری به عنوان مهم",
"lastUpdatedBy": "به‌روزرسانی توسط {name}",

View File

@@ -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}",

View File

@@ -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,7 +384,8 @@ export function CasesPage() {
: 'border-border hover:border-primary/40'
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<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>
@@ -388,6 +400,13 @@ export function CasesPage() {
</Badge>
) : null}
</div>
{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">
{item.patient.mobile}
</div>
@@ -495,6 +514,7 @@ export function CasesPage() {
visibleToClinic,
});
setCommentCount((n) => n + 1);
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {

View File

@@ -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) => {

View File

@@ -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 {

View 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>
);
}

View File

@@ -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>
);
})}

View File

@@ -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 {

View 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;
},
};

View 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]);
}

View 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;
}

View File

@@ -6,6 +6,7 @@ export interface LabCaseListItem {
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
hasUnread: boolean;
clinic: { id: string; name: string };
patient: {
id: string;