From d383a4abc30bf879b09a6266c673088c70cc1e91 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 14 Jul 2026 03:06:56 +0330 Subject: [PATCH] improvement: some new gadgets added to dashboard. some new functionality added to existed gadgets. --- .cursor/rules/dyolink-overview.mdc | 6 +- .cursor/rules/lab-cases.mdc | 14 ++ .cursor/rules/lab-tasks.mdc | 1 + .cursor/skills/lab-cases/SKILL.md | 42 ++++ .cursor/skills/lab-tasks/SKILL.md | 3 + .cursor/skills/today-dashboard/SKILL.md | 50 +++++ AGENTS.md | 8 +- backend/src/modules/cases/cases.module.ts | 4 +- backend/src/modules/cases/cases.service.ts | 120 ++++++----- backend/src/modules/cases/dto/cases.dto.ts | 2 +- backend/src/modules/tasks/dto/tasks.dto.ts | 22 ++ backend/src/modules/tasks/tasks.service.ts | 6 + backend/src/modules/today/today.service.ts | 202 +++++++++++++++--- frontend/messages/en.json | 9 +- frontend/messages/fa.json | 9 +- frontend/messages/nl.json | 9 +- .../components/lab/parseTasksSearchParams.ts | 54 +++++ .../src/components/lab/tasksViewDefaults.ts | 6 + .../src/components/staff/staffRowHighlight.ts | 20 ++ .../src/components/today/today-deep-links.ts | 28 +++ .../components/today/today-gadget-order.ts | 3 + .../src/components/today/widget-registry.ts | 42 +++- frontend/src/components/ui/lab/CasesPage.tsx | 101 ++++++--- .../ui/lab/LabCaseProsthesisGroupsList.tsx | 60 ++++++ frontend/src/components/ui/lab/TasksPage.tsx | 37 +++- .../ui/staff/StaffMembersMobileList.tsx | 17 +- .../src/components/ui/staff/StaffPage.tsx | 42 +++- .../src/components/ui/today/TodayBarChart.tsx | 41 ++-- .../ui/today/TodayBarChartTooltip.tsx | 49 +++++ .../components/ui/today/TodayDashboard.tsx | 53 ++++- .../TodayPartnerCasesStackedBarChart.tsx | 12 ++ .../ui/treatment/TreatmentLabCasesPanel.tsx | 42 +--- frontend/src/types/cases.ts | 16 +- frontend/src/types/today.ts | 5 +- 34 files changed, 944 insertions(+), 191 deletions(-) create mode 100644 .cursor/rules/lab-cases.mdc create mode 100644 .cursor/skills/lab-cases/SKILL.md create mode 100644 .cursor/skills/today-dashboard/SKILL.md create mode 100644 frontend/src/components/lab/parseTasksSearchParams.ts create mode 100644 frontend/src/components/staff/staffRowHighlight.ts create mode 100644 frontend/src/components/today/today-deep-links.ts create mode 100644 frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx create mode 100644 frontend/src/components/ui/today/TodayBarChartTooltip.tsx diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index 08edc17..92cb51c 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -28,4 +28,8 @@ Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form d ## Treatment / appointment colors -Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. +Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. **Prosthesis** colors/labels: `components/treatment/prosthesisTypeDisplay.ts` — use on lab Cases/Tasks/Today charts. + +## Today dashboard + +CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `components/today/today-deep-links.ts`. Task KPIs need `TAB_TASKS_READ` (no owner bypass). Skill: `.cursor/skills/today-dashboard/SKILL.md`. diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc new file mode 100644 index 0000000..e7ab10c --- /dev/null +++ b/.cursor/rules/lab-cases.mdc @@ -0,0 +1,14 @@ +--- +description: Lab Cases tab — prosthesis filter, auto-select, list cards +globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** +alwaysApply: false +--- + +# Lab Cases + +- **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type. +- **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides. +- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. +- **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab. + +Skill: `.cursor/skills/lab-cases/SKILL.md` diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index 4b0e437..884a1a9 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -14,5 +14,6 @@ alwaysApply: false - **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks. - **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks. - **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. +- **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks. Full map: `.cursor/skills/lab-tasks/SKILL.md` diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md new file mode 100644 index 0000000..a29da96 --- /dev/null +++ b/.cursor/skills/lab-cases/SKILL.md @@ -0,0 +1,42 @@ +--- +name: dyolink-lab-cases +description: Lab Cases tab — list, filters, detail panel, assignment, card UX. Use when changing CasesPage, cases API, or case list cards. +--- + +# Lab Cases tab + +**UI:** [`frontend/src/components/ui/lab/CasesPage.tsx`](frontend/src/components/ui/lab/CasesPage.tsx) +**Backend:** [`backend/src/modules/cases/`](backend/src/modules/cases/) +**Shared prosthesis rows:** [`LabCaseProsthesisGroupsList.tsx`](frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx) (also used in Treatment lab shipments rail) + +## List behavior + +- **Default sort:** `sentAt` desc (newest first). +- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. +- **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching). + +## Filters (`GET /cases`) + +| Param | Behavior | +|-------|----------| +| `prosthesisTypeCode` | Cases with **≥1 task** of that prosthesis type (`tasks.some`) — not treatment-type filter | +| `clinicOrganizationId` | From URL (`Today` case partners chart) or dropdown | +| `q`, `sentFrom`, `sentTo` | Search + date range | + +Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (distinct codes from sent-case tasks, catalog-ordered). + +## List card UI + +Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. + +List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. + +## Detail panel + +- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) +- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes +- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) + +## Permissions + +`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes. diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index d9db181..6d1e4a8 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -40,6 +40,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. | `stepCompleted` | `GET /tasks` | Workflow step dropdown | | `pinImportant` | `GET /tasks` | Important first (sort pin) | | `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | +| `prosthesisTypeCode` | `GET /tasks` | From Today prosthesis chart deep link | +| `unassignedOnly` | `GET /tasks` | Tasks with no assignee | | `overdue` | `GET /tasks` | Cases with due date before today and at least one in-progress task | | `sortBy=dueDate` | `GET /tasks` | Sort by `LabCase.dueDate` (flat list; grouping off) | | Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | @@ -55,6 +57,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. - **Overdue cases:** `overdue=true` — `LabCase.dueDate` before start of UTC day **and** at least one task still `IN_PROGRESS`. Shown with error badge on Cases list/detail and task case headers. - **Sort by due date:** `sortBy=dueDate` — flat list (grouping off); tiebreakers match other non-date sorts. - **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`. +- **URL state:** `parseTasksSearchParams` applies Today deep-link query params on mount (`importantOnly`, `overdueOnly`, `unassignedOnly`, `prosthesisTypeCode`, `status`, sort). - **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task. - **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch. diff --git a/.cursor/skills/today-dashboard/SKILL.md b/.cursor/skills/today-dashboard/SKILL.md new file mode 100644 index 0000000..8d5cbd1 --- /dev/null +++ b/.cursor/skills/today-dashboard/SKILL.md @@ -0,0 +1,50 @@ +--- +name: dyolink-today-dashboard +description: Today tab dashboard — KPIs, charts, deep links, gadget registry. Use when adding/changing Today widgets, chart clicks, or tab navigation from Today. +--- + +# Today dashboard + +**UI:** [`frontend/src/components/ui/today/TodayDashboard.tsx`](frontend/src/components/ui/today/TodayDashboard.tsx) +**Backend:** [`backend/src/modules/today/today.service.ts`](backend/src/modules/today/today.service.ts) +**Registry:** [`frontend/src/components/today/widget-registry.ts`](frontend/src/components/today/widget-registry.ts) +**Deep links:** [`frontend/src/components/today/today-deep-links.ts`](frontend/src/components/today/today-deep-links.ts) +**Gadget order:** [`frontend/src/components/today/today-gadget-order.ts`](frontend/src/components/today/today-gadget-order.ts) + +## Adding a KPI widget + +1. `TodayWidgetKey` in [`frontend/src/types/today.ts`](frontend/src/types/today.ts) +2. Loader in `today.service.ts` (gate with same permission as target tab) +3. Entry in `TODAY_KPI_DEFINITIONS` — `isVisible`, `formatValue`, `href` or `resolveHref(widgets)` for dynamic links +4. `TODAY_KPI_GADGET_FEATURE` in `today-gadget-order.ts` +5. i18n `today.widget*` keys in **en, fa, nl** + +KPIs with count `0` still register; `getVisibleTodayKpis` hides when `formatValue` returns `null` (widget missing from API). + +## Deep links & chart clicks + +| Source | Target | +|--------|--------| +| Task KPIs | `/tasks?…` via `todayDeepLinks` + `parseTasksSearchParams` | +| Tasks by prosthesis chart (LAB) | `tasksByProsthesis(code)` | +| Case partners chart (LAB) | `casesByClinic(clinicOrgId)` | +| Providers w/o hours (CLINIC) | `staffMissingWorkingHours(membershipIds)` — widget returns `membershipIds[]`; Staff highlights rows (`STAFF_ROW_HIGHLIGHT_CLASS`) | + +Use `useRouter` from `@/i18n/navigation` for chart clicks. KPI cards use `Link`/`href` on `KpiCard`. + +## Charts (LAB examples) + +- **Tasks by prosthesis** — `TodayBarChart` + `colorForCode` from prosthesis catalog; `canViewTasks` +- **Cases due this week** — `casesDueWeek` buckets (next 7 local days); active sent cases with due date + in-progress task; `canViewCases`; empty card still shown +- **Lab task activity** — stacked week chart; `canViewCases \|\| canViewTasks` + +Week day labels: `useTodayDayLabelFormatter` + `mapWeekChartBuckets`. + +## Permissions (task KPIs) + +`TAB_TASKS_READ` / `TAB_TASKS_EDIT` — **no owner bypass** (unlike Cases). LAB owner needs tasks participation opt-in for task gadgets. + +## Deferred / planned + +- **Unread lab updates** (clinic KPI) → `/treatment?labUpdates=1`, scope `updates`, highlight first unread shipment (reuse `GET /treatments/lab-cases/unread`) +- Treatment mix bar click, lab pending send KPI, lab activity day click diff --git a/AGENTS.md b/AGENTS.md index dfe31c4..038f64a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,11 @@ frontend/src/ - **Unread semantics**: Treatment tab badge = count of unread cases (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. -**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. +**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from 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`. + +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. See `.cursor/skills/lab-cases/SKILL.md`. + +**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`. ## Backend layout @@ -74,7 +78,9 @@ 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-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | | `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors | +| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | | `.cursor/skills/i18n-formatting/` | Dates, times, numbers, Jalali picker, RTL formatting | diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts index baafc43..2f7ddc5 100644 --- a/backend/src/modules/cases/cases.module.ts +++ b/backend/src/modules/cases/cases.module.ts @@ -2,12 +2,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 { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module'; +import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module'; import { CasesController } from './cases.controller'; import { CasesService } from './cases.service'; @Module({ - imports: [TreatmentCatalogModule, NotificationsModule], + imports: [ProsthesisCatalogModule, NotificationsModule], controllers: [CasesController], providers: [CasesService, PrismaService, LabOrgGuard], exports: [CasesService], diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 195fa4a..bba9f53 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -12,7 +12,7 @@ import { CatalogLabelService, normalizeCatalogLocale, } from '../catalog/catalog-label.service'; -import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; +import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { @@ -92,7 +92,7 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ export class CasesService { constructor( private readonly prisma: PrismaService, - private readonly treatmentCatalog: TreatmentCatalogService, + private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, ) {} @@ -107,8 +107,8 @@ export class CasesService { async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) { await this.assertCanReadCases(actorUserId, labOrganizationId); - if (query.treatmentType) { - this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + if (query.prosthesisTypeCode) { + this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode); } const page = query.page ?? 1; @@ -127,12 +127,7 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - details: { - include: { - detail: { select: { treatmentType: true } }, - }, - }, - tasks: { select: { id: true, status: true } }, + tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -169,43 +164,48 @@ export class CasesService { async listFilterOptions(labOrganizationId: string, actorUserId: string) { await this.assertCanReadCases(actorUserId, labOrganizationId); - const rows = await this.prisma.labCase.findMany({ - where: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, - select: { - treatment: { - select: { - organization: { select: { id: true, name: true } }, + const [clinicRows, taskRows] = await Promise.all([ + this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { + treatment: { + select: { + organization: { select: { id: true, name: true } }, + }, }, }, - details: { - select: { detail: { select: { treatmentType: true } } }, + }), + this.prisma.labCaseTask.findMany({ + where: { + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, }, - }, - }); + select: { prosthesisTypeCode: true }, + distinct: ['prosthesisTypeCode'], + }), + ]); const clinicsById = new Map(); - const typeCodes = new Set(); - - for (const row of rows) { + for (const row of clinicRows) { clinicsById.set(row.treatment.organization.id, row.treatment.organization); - for (const link of row.details) { - typeCodes.add(link.detail.treatmentType); - } } - const catalog = await this.treatmentCatalog.list(); - const treatmentTypes = catalog - .filter((entry) => entry.labDependent && typeCodes.has(entry.code)) - .map((entry) => ({ code: entry.code, labDependent: entry.labDependent })); + const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode)); + const catalog = await this.prosthesisCatalog.list(); + const prosthesisTypes = catalog + .filter((entry) => typeCodes.has(entry.code)) + .map((entry) => ({ code: entry.code })); return { success: true, data: { clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), - treatmentTypes, + prosthesisTypes, }, }; } @@ -216,8 +216,8 @@ export class CasesService { labOrganizationId: string, query: ListLabCasesDto, ) { - if (query.treatmentType) { - this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + if (query.prosthesisTypeCode) { + this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode); } const page = query.page ?? 1; @@ -240,12 +240,7 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, - details: { - include: { - detail: { select: { treatmentType: true } }, - }, - }, - tasks: { select: { id: true, status: true } }, + tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } }, }, orderBy: [{ sentAt: 'desc' }], skip, @@ -516,10 +511,10 @@ export class CasesService { ...(query.clinicOrganizationId ? { treatment: { organizationId: query.clinicOrganizationId } } : {}), - ...(query.treatmentType + ...(query.prosthesisTypeCode ? { - details: { - some: { detail: { treatmentType: query.treatmentType } }, + tasks: { + some: { prosthesisTypeCode: query.prosthesisTypeCode }, }, } : {}), @@ -565,10 +560,14 @@ export class CasesService { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; }; - details: Array<{ detail: { treatmentType: string } }>; - tasks: Array<{ id: string; status: LabTaskStatus }>; + tasks: Array<{ + id: string; + status: LabTaskStatus; + prosthesisTypeCode: string; + teeth: Prisma.JsonValue; + }>; }) { - const treatmentType = lc.details[0]?.detail.treatmentType ?? null; + const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks); const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; return { @@ -584,7 +583,7 @@ export class CasesService { lastName: lc.treatment.patient.lastName, mobile: lc.treatment.patient.mobile, }, - treatmentType, + prosthesisGroups, taskProgress: { completed: completedTasks, total: lc.tasks.length, @@ -651,6 +650,29 @@ export class CasesService { }; } + private buildProsthesisGroupsFromTasks( + tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>, + ): Array<{ prosthesisTypeCode: string; teeth: string[] }> { + const prosthesisByCode = new Map(); + + for (const task of tasks) { + if (!task.prosthesisTypeCode) continue; + const teeth = normalizeTaskTeeth(task.teeth); + const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? []; + list.push(...teeth); + prosthesisByCode.set(task.prosthesisTypeCode, list); + } + + return [...prosthesisByCode.entries()] + .map(([prosthesisTypeCode, teeth]) => ({ + prosthesisTypeCode, + teeth: [...new Set(teeth)].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ), + })) + .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); + } + private groupTasks( tasks: LabCaseTaskWithRelations[], prosthesisLabels: Map, diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index be290bb..74ecc98 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -23,7 +23,7 @@ export class ListLabCasesDto { @IsOptional() @IsString() - treatmentType?: string; + prosthesisTypeCode?: string; @IsOptional() @IsDateString() diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 1ce3e0e..531d0ff 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -94,6 +94,17 @@ export class ListLabTasksDto { @IsBoolean() overdue?: boolean; + /** When true, only tasks with no assignee. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + unassignedOnly?: boolean; + + /** Narrow list to a single prosthesis type code. */ + @IsOptional() + @IsString() + prosthesisTypeCode?: string; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; @@ -172,6 +183,17 @@ export class LocateTaskPageDto { @IsBoolean() overdue?: boolean; + /** When true, only tasks with no assignee. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + unassignedOnly?: boolean; + + /** Narrow list to a single prosthesis type code. */ + @IsOptional() + @IsString() + prosthesisTypeCode?: string; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 41e3c42..c588e75 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -337,6 +337,10 @@ export class TasksService { }, ...(status !== undefined ? { status } : {}), ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}), + ...(query.unassignedOnly ? { assigneeUserId: null } : {}), + ...(query.prosthesisTypeCode?.trim() + ? { prosthesisTypeCode: query.prosthesisTypeCode.trim() } + : {}), }; const stepCompleted = query.stepCompleted?.trim(); @@ -385,6 +389,8 @@ export class TasksService { stepCompleted: query.stepCompleted, assignedToMe: query.assignedToMe, overdue: query.overdue, + unassignedOnly: query.unassignedOnly, + prosthesisTypeCode: query.prosthesisTypeCode, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 79e9745..6ec22e4 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -18,6 +18,7 @@ import { type CatalogLocale, } from '../catalog/catalog-label.service'; import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; +import { startOfUtcDay } from '../../common/lab-case-due-date'; import { TodaySummaryQueryDto } from './dto/today-summary-query.dto'; type ChartBucket = { code: string; label: string; count: number }; @@ -52,6 +53,7 @@ type TodayCharts = { appointmentsWeekMine?: ChartBucket[]; labTaskActivityWeek?: StackedDayBucket[]; casePartnersMonth?: PartnerCasesBucket[]; + casesDueWeek?: ChartBucket[]; efficiencyReport?: ChartBucket[]; }; @@ -88,9 +90,11 @@ type TodayWidgets = { casesInProgress?: { count: number }; tasksInProgress?: { count: number }; importantTasks?: { count: number }; + overdueCases?: { count: number }; + unassignedTasks?: { count: number }; pendingConnections?: { count: number }; pendingStaffInvites?: { count: number }; - providersWithoutWorkingHours?: { count: number }; + providersWithoutWorkingHours?: { count: number; membershipIds: string[] }; }; @Injectable() @@ -225,6 +229,14 @@ export class TodayService { ); tasks.push(this.loadCasesInProgress(organizationId, widgets)); tasks.push(this.loadCaseCompletion(organizationId, charts)); + tasks.push( + this.loadCasesDueWeek( + organizationId, + from, + query.utcOffsetMinutes, + charts, + ), + ); } if (this.canEditCases(membership.isOwner, permissionNames)) { @@ -243,6 +255,8 @@ export class TodayService { if (this.canViewTasks(membership.isOwner, permissionNames)) { tasks.push(this.loadTasksInProgress(organizationId, widgets)); tasks.push(this.loadImportantTasks(organizationId, widgets)); + tasks.push(this.loadOverdueCases(organizationId, widgets)); + tasks.push(this.loadUnassignedTasks(organizationId, widgets)); tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts)); } @@ -540,6 +554,32 @@ export class TodayService { widgets.importantTasks = { count }; } + private async loadOverdueCases(labOrganizationId: string, widgets: TodayWidgets) { + const count = await this.prisma.labCase.count({ + where: { + sentAt: { not: null }, + dueDate: { not: null, lt: startOfUtcDay() }, + sends: { some: { organizationId: labOrganizationId } }, + tasks: { some: { status: LabTaskStatus.IN_PROGRESS } }, + }, + }); + widgets.overdueCases = { count }; + } + + private async loadUnassignedTasks(labOrganizationId: string, widgets: TodayWidgets) { + const count = await this.prisma.labCaseTask.count({ + where: { + status: LabTaskStatus.IN_PROGRESS, + assigneeUserId: null, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + }); + widgets.unassignedTasks = { count }; + } + private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) { const links = await this.prisma.organizationLink.findMany({ where: { @@ -772,17 +812,47 @@ export class TodayService { widgets.pendingStaffInvites = { count }; } + private async listTreatmentParticipatingMembers(organizationId: string) { + return this.prisma.membership.findMany({ + where: { + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + permissions: { + some: { + permission: { name: 'TAB_TREATMENT_EDIT' }, + }, + }, + }, + select: { + id: true, + isOwner: true, + user: { select: { id: true, name: true } }, + }, + }); + } + private async loadAppointmentsByProvider( organizationId: string, from: Date, to: Date, charts: TodayCharts, ) { + const members = await this.listTreatmentParticipatingMembers(organizationId); + + if (members.length === 0) { + charts.appointmentsByProvider = []; + return; + } + + const participatingUserIds = new Set(members.map((member) => member.user.id)); + const ownerMember = members.find((member) => member.isOwner); + const appointments = await this.prisma.appointment.findMany({ where: { organizationId, startAt: { lt: to }, endAt: { gt: from }, + providerUserId: { in: [...participatingUserIds] }, }, select: { providerUserId: true }, }); @@ -795,25 +865,33 @@ export class TodayService { ); } - if (countsByProvider.size === 0) { - charts.appointmentsByProvider = []; - return; + const rows = members.map((member) => ({ + userId: member.user.id, + label: member.user.name, + count: countsByProvider.get(member.user.id) ?? 0, + isOwner: member.isOwner, + })); + + const sorted = [...rows].sort((a, b) => b.count - a.count); + let top = sorted.slice(0, 8); + + if (ownerMember) { + const ownerUserId = ownerMember.user.id; + const ownerInTop = top.some((row) => row.userId === ownerUserId); + if (!ownerInTop) { + const ownerRow = rows.find((row) => row.userId === ownerUserId)!; + if (top.length >= 8) { + top = [...top.slice(0, 7), ownerRow]; + } else { + top = [...top, ownerRow]; + } + } } - const sorted = [...countsByProvider.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 8); - - const users = await this.prisma.user.findMany({ - where: { id: { in: sorted.map(([userId]) => userId) } }, - select: { id: true, name: true }, - }); - const nameById = new Map(users.map((user) => [user.id, user.name])); - - charts.appointmentsByProvider = sorted.map(([userId, count]) => ({ - code: userId, - label: nameById.get(userId) ?? userId, - count, + charts.appointmentsByProvider = top.map((row) => ({ + code: row.userId, + label: row.label, + count: row.count, })); } @@ -821,21 +899,10 @@ export class TodayService { organizationId: string, widgets: TodayWidgets, ) { - const members = await this.prisma.membership.findMany({ - where: { - organizationId, - OR: [{ isOwner: true }, { isActive: true }], - permissions: { - some: { - permission: { name: 'TAB_TREATMENT_EDIT' }, - }, - }, - }, - select: { id: true }, - }); + const members = await this.listTreatmentParticipatingMembers(organizationId); if (members.length === 0) { - widgets.providersWithoutWorkingHours = { count: 0 }; + widgets.providersWithoutWorkingHours = { count: 0, membershipIds: [] }; return; } @@ -844,12 +911,52 @@ export class TodayService { members.map((member) => member.id), ); - const count = members.filter((member) => { + const missingHoursMembers = members.filter((member) => { const blocks = scheduleBlocksByMembership.get(member.id) ?? []; return blocks.length === 0; - }).length; + }); - widgets.providersWithoutWorkingHours = { count }; + widgets.providersWithoutWorkingHours = { + count: missingHoursMembers.length, + membershipIds: missingHoursMembers.map((member) => member.id), + }; + } + + private async loadCasesDueWeek( + labOrganizationId: string, + rangeStart: Date, + utcOffsetMinutes: number | undefined, + charts: TodayCharts, + ) { + const dayBuckets = buildNextSevenLocalDayBuckets(rangeStart, utcOffsetMinutes); + const weekStart = dayBuckets[0]?.start ?? rangeStart; + const weekEnd = dayBuckets[dayBuckets.length - 1]?.end ?? rangeStart; + const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; + const counts = new Map(dayBuckets.map((bucket) => [bucket.code, 0])); + + const cases = await this.prisma.labCase.findMany({ + where: { + dueDate: { not: null, gte: weekStart, lt: weekEnd }, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + tasks: { some: { status: LabTaskStatus.IN_PROGRESS } }, + }, + select: { dueDate: true }, + }); + + for (const labCase of cases) { + if (!labCase.dueDate) continue; + const dayKey = localDayKeyFromDate(labCase.dueDate, offsetMs); + if (counts.has(dayKey)) { + counts.set(dayKey, (counts.get(dayKey) ?? 0) + 1); + } + } + + charts.casesDueWeek = dayBuckets.map((bucket) => ({ + code: bucket.code, + label: bucket.label, + count: counts.get(bucket.code) ?? 0, + })); } private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) { @@ -1279,6 +1386,33 @@ function buildLastSevenLocalDayBuckets( return buckets; } +function buildNextSevenLocalDayBuckets( + rangeStart: Date, + utcOffsetMinutes?: number, +): LocalDayBucket[] { + const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; + const dayMs = 86_400_000; + const localMs = rangeStart.getTime() + offsetMs; + const local = new Date(localMs); + local.setUTCHours(0, 0, 0, 0); + const dayStart = new Date(local.getTime() - offsetMs); + const buckets: LocalDayBucket[] = []; + + for (let index = 0; index < 7; index += 1) { + const start = new Date(dayStart.getTime() + index * dayMs); + const end = new Date(start.getTime() + dayMs); + const code = localDayKeyFromDate(start, offsetMs); + buckets.push({ + code, + label: code, + start, + end, + }); + } + + return buckets; +} + function localDayKeyFromDate(date: Date, offsetMs: number): string { const localMs = date.getTime() + offsetMs; const local = new Date(localMs); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e310e34..51d2cb7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -209,6 +209,8 @@ "widgetCasesInProgress": "Cases In Progress", "widgetTasksInProgress": "Tasks In Progress", "widgetImportantTasks": "Important Tasks", + "widgetOverdueCases": "Overdue Cases", + "widgetUnassignedTasks": "Unassigned Tasks", "widgetPendingConnections": "Pending Connections", "widgetProvidersWithoutWorkingHours": "Providers Without Working Hours", "widgetPendingStaffInvites": "Pending Staff Invites", @@ -243,6 +245,8 @@ "chartTreatmentPlanCompletionRatio": "With treatment plan", "chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis", "chartTasksByProsthesisSubtitle": "Current workload mix", + "chartCasesDueWeekTitle": "Cases Due This Week", + "chartCasesDueWeekSubtitle": "Active cases with a due date in the next 7 days", "chartCasePartnersClinicTitle": "Cases by Lab", "chartCasePartnersLabTitle": "Cases by Clinic", "chartCasePartnersSubtitle": "Last 30 days", @@ -444,8 +448,8 @@ "assignedTo": "Assigned to {name}", "filterClinic": "Clinic", "filterClinicAll": "All clinics", - "filterTreatmentType": "Treatment type", - "filterTreatmentTypeAll": "All types", + "filterProsthesisType": "Prosthesis type", + "filterProsthesisTypeAll": "All types", "filterSentFrom": "Sent from", "filterSentTo": "Sent to", "clearFilters": "Clear filters", @@ -492,6 +496,7 @@ "importantOnly": "Important first", "assignedToMe": "Assigned to me", "overdueOnly": "Overdue cases only", + "unassignedOnly": "Unassigned only", "assignedToStaff": "Assigned to {name}", "resetView": "Reset filters & sort", "showInCase": "Show in case", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 1541622..500daaf 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -209,6 +209,8 @@ "widgetCasesInProgress": "پرونده‌های در حال انجام", "widgetTasksInProgress": "وظایف در حال انجام", "widgetImportantTasks": "وظایف مهم", + "widgetOverdueCases": "پرونده‌های معوق", + "widgetUnassignedTasks": "وظایف بدون مسئول", "widgetPendingConnections": "درخواست‌های اتصال در انتظار", "widgetProvidersWithoutWorkingHours": "ارائه‌دهندگان بدون ساعات کاری", "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار", @@ -243,6 +245,8 @@ "chartTreatmentPlanCompletionRatio": "دارای طرح درمان", "chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز", "chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی", + "chartCasesDueWeekTitle": "پرونده‌های موعددار این هفته", + "chartCasesDueWeekSubtitle": "پرونده‌های فعال با موعد تحویل در ۷ روز آینده", "chartCasePartnersClinicTitle": "کیس‌ها بر اساس لابراتوار", "chartCasePartnersLabTitle": "کیس‌ها بر اساس کلینیک", "chartCasePartnersSubtitle": "۳۰ روز گذشته", @@ -444,8 +448,8 @@ "assignedTo": "واگذار شده به {name}", "filterClinic": "کلینیک", "filterClinicAll": "همه کلینیک‌ها", - "filterTreatmentType": "نوع درمان", - "filterTreatmentTypeAll": "همه انواع", + "filterProsthesisType": "نوع پروتز", + "filterProsthesisTypeAll": "همه انواع", "filterSentFrom": "ارسال از", "filterSentTo": "ارسال تا", "clearFilters": "پاک کردن فیلترها", @@ -493,6 +497,7 @@ "importantOnly": "مهم‌ها در ابتدا", "assignedToMe": "واگذار شده به من", "overdueOnly": "فقط پرونده‌های عقب‌افتاده", + "unassignedOnly": "فقط بدون مسئول", "assignedToStaff": "واگذار شده به {name}", "resetView": "بازنشانی فیلترها و مرتب‌سازی", "showInCase": "نمایش در پرونده", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index bb158d5..399dcd9 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -209,6 +209,8 @@ "widgetCasesInProgress": "Cases in uitvoering", "widgetTasksInProgress": "Taken in uitvoering", "widgetImportantTasks": "Belangrijke taken", + "widgetOverdueCases": "Achterstallige cases", + "widgetUnassignedTasks": "Niet-toegewezen taken", "widgetPendingConnections": "Openstaande koppelingsverzoeken", "widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden", "widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen", @@ -243,6 +245,8 @@ "chartTreatmentPlanCompletionRatio": "Met behandelplan", "chartTasksByProsthesisTitle": "Lopende taken per prothese", "chartTasksByProsthesisSubtitle": "Huidige werklastmix", + "chartCasesDueWeekTitle": "Cases met deadline deze week", + "chartCasesDueWeekSubtitle": "Actieve cases met een deadline in de komende 7 dagen", "chartCasePartnersClinicTitle": "Cases per lab", "chartCasePartnersLabTitle": "Cases per kliniek", "chartCasePartnersSubtitle": "Afgelopen 30 dagen", @@ -444,8 +448,8 @@ "assignedTo": "Toegewezen aan {name}", "filterClinic": "Kliniek", "filterClinicAll": "Alle klinieken", - "filterTreatmentType": "Behandeltype", - "filterTreatmentTypeAll": "Alle types", + "filterProsthesisType": "Prothesetype", + "filterProsthesisTypeAll": "Alle types", "filterSentFrom": "Verzonden vanaf", "filterSentTo": "Verzonden tot", "clearFilters": "Filters wissen", @@ -493,6 +497,7 @@ "importantOnly": "Belangrijke cases eerst", "assignedToMe": "Toegewezen aan mij", "overdueOnly": "Alleen te late cases", + "unassignedOnly": "Alleen niet-toegewezen", "assignedToStaff": "Toegewezen aan {name}", "resetView": "Filters en sortering resetten", "showInCase": "In case tonen", diff --git a/frontend/src/components/lab/parseTasksSearchParams.ts b/frontend/src/components/lab/parseTasksSearchParams.ts new file mode 100644 index 0000000..4858519 --- /dev/null +++ b/frontend/src/components/lab/parseTasksSearchParams.ts @@ -0,0 +1,54 @@ +import type { LabTaskStatus, TaskSortField } from '@/types/cases'; +import type { TasksViewState } from '@/components/lab/tasksViewDefaults'; + +/** Apply Tasks tab URL query params from Today deep links. */ +export function parseTasksSearchParams( + searchParams: URLSearchParams, +): Partial { + const partial: Partial = {}; + + if (searchParams.get('importantOnly') === '1') { + partial.importantOnly = true; + } + if (searchParams.get('overdueOnly') === '1') { + partial.overdueOnly = true; + } + if (searchParams.get('unassignedOnly') === '1') { + partial.unassignedOnly = true; + } + + const status = searchParams.get('status'); + if (status === 'IN_PROGRESS' || status === 'COMPLETED') { + partial.statusFilter = status; + } else if (status === 'all') { + partial.statusFilter = ''; + } + + const prosthesisTypeCode = searchParams.get('prosthesisTypeCode')?.trim(); + if (prosthesisTypeCode) { + partial.prosthesisTypeCode = prosthesisTypeCode; + partial.sortBy = 'prosthesis'; + partial.sortDir = 'desc'; + } + + const sortBy = searchParams.get('sortBy'); + if ( + sortBy === 'date' || + sortBy === 'status' || + sortBy === 'clinic' || + sortBy === 'patient' || + sortBy === 'important' || + sortBy === 'prosthesis' || + sortBy === 'taskType' || + sortBy === 'dueDate' + ) { + partial.sortBy = sortBy as TaskSortField; + } + + const sortDir = searchParams.get('sortDir'); + if (sortDir === 'asc' || sortDir === 'desc') { + partial.sortDir = sortDir; + } + + return partial; +} diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index 6d8be13..8350846 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -10,6 +10,8 @@ export type TasksViewState = { importantOnly: boolean; assignedToMe: boolean; overdueOnly: boolean; + unassignedOnly: boolean; + prosthesisTypeCode: string; page: number; highlightTaskId: string | null; }; @@ -24,6 +26,8 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { importantOnly: false, assignedToMe: false, overdueOnly: false, + unassignedOnly: false, + prosthesisTypeCode: '', page: 1, highlightTaskId: null, }; @@ -41,6 +45,8 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly && state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly && + state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly && + state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId ); diff --git a/frontend/src/components/staff/staffRowHighlight.ts b/frontend/src/components/staff/staffRowHighlight.ts new file mode 100644 index 0000000..dcc2589 --- /dev/null +++ b/frontend/src/components/staff/staffRowHighlight.ts @@ -0,0 +1,20 @@ +export const STAFF_ROW_HIGHLIGHT_CLASS = + 'relative bg-primary/10 ring-2 ring-inset ring-primary/50 shadow-[0_0_16px_rgba(99,102,241,0.2)]'; + +export function parseHighlightMembershipIds(searchParams: URLSearchParams): Set { + const raw = searchParams.get('highlightMembershipIds'); + if (!raw) return new Set(); + return new Set( + raw + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ); +} + +export function isStaffRowHighlighted( + membershipId: string, + highlightIds: Set, +): boolean { + return highlightIds.has(membershipId); +} diff --git a/frontend/src/components/today/today-deep-links.ts b/frontend/src/components/today/today-deep-links.ts new file mode 100644 index 0000000..06de524 --- /dev/null +++ b/frontend/src/components/today/today-deep-links.ts @@ -0,0 +1,28 @@ +import type { TodayWidgetKey } from '@/types/today'; + +/** Deep links from Today dashboard gadgets into feature tabs. */ +export const todayDeepLinks = { + tasksInProgress: '/tasks?status=IN_PROGRESS', + importantTasks: '/tasks?importantOnly=1&status=IN_PROGRESS', + overdueCases: '/tasks?overdueOnly=1&status=IN_PROGRESS', + unassignedTasks: '/tasks?unassignedOnly=1&status=IN_PROGRESS', + tasksByProsthesis: (prosthesisTypeCode: string) => + `/tasks?prosthesisTypeCode=${encodeURIComponent(prosthesisTypeCode)}&status=IN_PROGRESS&sortBy=prosthesis&sortDir=desc`, + casesByClinic: (clinicOrganizationId: string) => + `/cases?clinicOrganizationId=${encodeURIComponent(clinicOrganizationId)}`, + staffMissingWorkingHours: (membershipIds: string[]) => { + if (membershipIds.length === 0) return '/staff'; + return `/staff?highlightMembershipIds=${membershipIds.map(encodeURIComponent).join(',')}`; + }, +} as const; + +const KPI_HREFS: Partial> = { + tasksInProgress: todayDeepLinks.tasksInProgress, + importantTasks: todayDeepLinks.importantTasks, + overdueCases: todayDeepLinks.overdueCases, + unassignedTasks: todayDeepLinks.unassignedTasks, +}; + +export function todayKpiHref(key: TodayWidgetKey, fallback: string): string { + return KPI_HREFS[key] ?? fallback; +} diff --git a/frontend/src/components/today/today-gadget-order.ts b/frontend/src/components/today/today-gadget-order.ts index 9d6d153..63ac7e1 100644 --- a/frontend/src/components/today/today-gadget-order.ts +++ b/frontend/src/components/today/today-gadget-order.ts @@ -36,6 +36,8 @@ export const TODAY_KPI_GADGET_FEATURE: Record = { 'chart-treatment-mix': 'treatment', 'chart-lab-task-activity': 'cases', 'chart-tasks-by-prosthesis': 'tasks', + 'chart-cases-due-week': 'cases', 'chart-case-partners-month': 'treatment', }; diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts index aa27100..afac58d 100644 --- a/frontend/src/components/today/widget-registry.ts +++ b/frontend/src/components/today/widget-registry.ts @@ -3,10 +3,12 @@ import { AlertCircle, CalendarDays, ClipboardList, + Clock, FlaskConical, Link2, Stethoscope, UserCog, + UserRound, Users, } from 'lucide-react'; import type { Organization } from '@/types/organization'; @@ -21,6 +23,7 @@ import { type OrgTypeName, } from '@/components/shared/permissions'; import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today'; +import { todayDeepLinks, todayKpiHref } from '@/components/today/today-deep-links'; export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default'; @@ -31,6 +34,8 @@ export interface TodayKpiDefinition { color: KpiCardColor; orgTypes: OrgTypeName[]; href: string; + /** When set, overrides static href using live widget payload (e.g. membership IDs). */ + resolveHref?: (widgets: TodaySummaryWidgets) => string; isVisible: (org: Organization | null) => boolean; formatValue: (widgets: TodaySummaryWidgets) => string | null; formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null; @@ -119,6 +124,13 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ color: 'yellow', orgTypes: ['CLINIC'], href: '/staff', + resolveHref: (widgets) => { + const value = widgets.providersWithoutWorkingHours; + if (!value || !('count' in value) || !value.membershipIds?.length) { + return '/staff'; + } + return todayDeepLinks.staffMissingWorkingHours(value.membershipIds); + }, isVisible: (org) => canViewStaff(org), formatValue: (widgets) => { const count = countWidget(widgets, 'providersWithoutWorkingHours'); @@ -157,7 +169,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ icon: ClipboardList, color: 'yellow', orgTypes: ['LAB'], - href: '/tasks', + href: todayKpiHref('tasksInProgress', '/tasks'), isVisible: (org) => canViewTasks(org), formatValue: (widgets) => { const count = countWidget(widgets, 'tasksInProgress'); @@ -170,13 +182,39 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ icon: AlertCircle, color: 'red', orgTypes: ['LAB'], - href: '/tasks', + href: todayKpiHref('importantTasks', '/tasks'), isVisible: (org) => canViewTasks(org), formatValue: (widgets) => { const count = countWidget(widgets, 'importantTasks'); return count === null ? null : String(count); }, }, + { + key: 'overdueCases', + titleKey: 'widgetOverdueCases', + icon: Clock, + color: 'red', + orgTypes: ['LAB'], + href: todayKpiHref('overdueCases', '/tasks'), + isVisible: (org) => canViewTasks(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'overdueCases'); + return count === null ? null : String(count); + }, + }, + { + key: 'unassignedTasks', + titleKey: 'widgetUnassignedTasks', + icon: UserRound, + color: 'purple', + orgTypes: ['LAB'], + href: todayKpiHref('unassignedTasks', '/tasks'), + isVisible: (org) => canViewTasks(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'unassignedTasks'); + return count === null ? null : String(count); + }, + }, { key: 'pendingConnections', titleKey: 'widgetPendingConnections', diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index 5d8bddd..c05fef4 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { canEditCases, canEditTasks } from '@/components/shared/permissions'; import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; +import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { formatCaseDateTime, @@ -18,6 +19,7 @@ import { notificationsApi } from '@/lib/api/notifications'; import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; import { casesApi } from '@/lib/api/cases'; import { tasksApi } from '@/lib/api/tasks'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { Badge } from '@/components/ui/shared/Badge'; @@ -26,7 +28,7 @@ import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackB import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { AppDateInput } from '@/components/ui/shared/AppDateInput'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { AssignableTaskStaff, CasesFilterOptions, @@ -48,7 +50,7 @@ export function CasesPage() { const [search, setSearch] = useState(''); const [clinicId, setClinicId] = useState(''); - const [treatmentType, setTreatmentType] = useState(''); + const [prosthesisTypeCode, setProsthesisTypeCode] = useState(''); const [sentFrom, setSentFrom] = useState(''); const [sentTo, setSentTo] = useState(''); const [page, setPage] = useState(1); @@ -62,8 +64,9 @@ export function CasesPage() { }); const [filterOptions, setFilterOptions] = useState({ clinics: [], - treatmentTypes: [], + prosthesisTypes: [], }); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [selectedCaseId, setSelectedCaseId] = useState(null); @@ -80,7 +83,13 @@ export function CasesPage() { const canEditComments = canEditTasks(currentOrganization); const locale = user?.language ?? 'en'; - const treatmentLabel = useCallback( + const prosthesisLabel = useCallback( + (code: string) => + prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code, + [prosthesisCatalog], + ); + + const treatmentDetailLabel = useCallback( (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), [treatmentCatalog], ); @@ -94,13 +103,13 @@ export function CasesPage() { ); const hasActiveFilters = Boolean( - search.trim() || clinicId || treatmentType || sentFrom || sentTo, + search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo, ); const loadCases = async (params: { q: string; clinicOrganizationId: string; - treatmentType: string; + prosthesisTypeCode: string; sentFrom: string; sentTo: string; page: number; @@ -111,7 +120,7 @@ export function CasesPage() { const response = await casesApi.list({ q: params.q.trim() || undefined, clinicOrganizationId: params.clinicOrganizationId || undefined, - treatmentType: params.treatmentType || undefined, + prosthesisTypeCode: params.prosthesisTypeCode || undefined, sentFrom: params.sentFrom || undefined, sentTo: params.sentTo || undefined, page: params.page, @@ -148,6 +157,7 @@ export function CasesPage() { useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); + void prosthesisCatalogApi.list().then((r) => setProsthesisCatalog(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); if (canEdit) { void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {}); @@ -156,11 +166,6 @@ export function CasesPage() { }, [canEdit]); useEffect(() => { - const caseIdFromUrl = searchParams.get('caseId'); - if (caseIdFromUrl) { - setSelectedCaseId(caseIdFromUrl); - setMobileDetailOpen(true); - } const clinicFromUrl = searchParams.get('clinicOrganizationId'); if (clinicFromUrl) { setClinicId(clinicFromUrl); @@ -168,17 +173,37 @@ export function CasesPage() { }, [searchParams]); useEffect(() => { - if (!selectedCaseId) { - setMobileDetailOpen(false); + if (loadingList) return; + + if (cases.length === 0) { + if (selectedCaseId !== null) { + setSelectedCaseId(null); + } + return; } - }, [selectedCaseId]); + + const urlCaseId = searchParams.get('caseId'); + if (urlCaseId && cases.some((item) => item.id === urlCaseId)) { + if (selectedCaseId !== urlCaseId) { + setSelectedCaseId(urlCaseId); + setMobileDetailOpen(true); + } + return; + } + + if (selectedCaseId && cases.some((item) => item.id === selectedCaseId)) { + return; + } + + setSelectedCaseId(cases[0].id); + }, [cases, loadingList, searchParams, selectedCaseId]); useEffect(() => { const timeout = setTimeout(() => { void loadCases({ q: search, clinicOrganizationId: clinicId, - treatmentType, + prosthesisTypeCode, sentFrom, sentTo, page, @@ -186,7 +211,13 @@ export function CasesPage() { }, search ? 300 : 0); return () => clearTimeout(timeout); // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload - }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); + }, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]); + + useEffect(() => { + if (!selectedCaseId) { + setMobileDetailOpen(false); + } + }, [selectedCaseId]); useEffect(() => { if (selectedCaseId) { @@ -222,7 +253,7 @@ export function CasesPage() { function clearFilters() { setSearch(''); setClinicId(''); - setTreatmentType(''); + setProsthesisTypeCode(''); setSentFrom(''); setSentTo(''); setPage(1); @@ -314,19 +345,19 @@ export function CasesPage() {