improvement/ux-overhaul up #61
@@ -28,4 +28,8 @@ Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form d
|
|||||||
|
|
||||||
## Treatment / appointment colors
|
## 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`.
|
||||||
|
|||||||
14
.cursor/rules/lab-cases.mdc
Normal file
14
.cursor/rules/lab-cases.mdc
Normal file
@@ -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`
|
||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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`
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
|
|||||||
42
.cursor/skills/lab-cases/SKILL.md
Normal file
42
.cursor/skills/lab-cases/SKILL.md
Normal file
@@ -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.
|
||||||
@@ -40,6 +40,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
||||||
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
||||||
| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user |
|
| `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 |
|
| `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) |
|
| `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) |
|
| 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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch.
|
||||||
|
|
||||||
|
|||||||
50
.cursor/skills/today-dashboard/SKILL.md
Normal file
50
.cursor/skills/today-dashboard/SKILL.md
Normal file
@@ -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
|
||||||
@@ -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).
|
- **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 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
|
## 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/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/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-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/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/frontend-structure/` | Moving components, auditing folder layout |
|
||||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||||
| `.cursor/skills/i18n-formatting/` | Dates, times, numbers, Jalali picker, RTL formatting |
|
| `.cursor/skills/i18n-formatting/` | Dates, times, numbers, Jalali picker, RTL formatting |
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
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 { CasesController } from './cases.controller';
|
||||||
import { CasesService } from './cases.service';
|
import { CasesService } from './cases.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TreatmentCatalogModule, NotificationsModule],
|
imports: [ProsthesisCatalogModule, NotificationsModule],
|
||||||
controllers: [CasesController],
|
controllers: [CasesController],
|
||||||
providers: [CasesService, PrismaService, LabOrgGuard],
|
providers: [CasesService, PrismaService, LabOrgGuard],
|
||||||
exports: [CasesService],
|
exports: [CasesService],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
CatalogLabelService,
|
CatalogLabelService,
|
||||||
normalizeCatalogLocale,
|
normalizeCatalogLocale,
|
||||||
} from '../catalog/catalog-label.service';
|
} 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 { normalizeTeeth } from '../treatments/treatment.utils';
|
||||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||||
import {
|
import {
|
||||||
@@ -92,7 +92,7 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
|||||||
export class CasesService {
|
export class CasesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||||
private readonly catalogLabels: CatalogLabelService,
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
private readonly labCaseActivity: LabCaseActivityService,
|
private readonly labCaseActivity: LabCaseActivityService,
|
||||||
) {}
|
) {}
|
||||||
@@ -107,8 +107,8 @@ export class CasesService {
|
|||||||
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
|
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
|
||||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
if (query.treatmentType) {
|
if (query.prosthesisTypeCode) {
|
||||||
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -127,12 +127,7 @@ export class CasesService {
|
|||||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
details: {
|
tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } },
|
||||||
include: {
|
|
||||||
detail: { select: { treatmentType: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tasks: { select: { id: true, status: true } },
|
|
||||||
},
|
},
|
||||||
orderBy: [{ sentAt: 'desc' }],
|
orderBy: [{ sentAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
@@ -169,43 +164,48 @@ export class CasesService {
|
|||||||
async listFilterOptions(labOrganizationId: string, actorUserId: string) {
|
async listFilterOptions(labOrganizationId: string, actorUserId: string) {
|
||||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
const rows = await this.prisma.labCase.findMany({
|
const [clinicRows, taskRows] = await Promise.all([
|
||||||
where: {
|
this.prisma.labCase.findMany({
|
||||||
sentAt: { not: null },
|
where: {
|
||||||
sends: { some: { organizationId: labOrganizationId } },
|
sentAt: { not: null },
|
||||||
},
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
select: {
|
},
|
||||||
treatment: {
|
select: {
|
||||||
select: {
|
treatment: {
|
||||||
organization: { select: { id: true, name: true } },
|
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<string, { id: string; name: string }>();
|
const clinicsById = new Map<string, { id: string; name: string }>();
|
||||||
const typeCodes = new Set<string>();
|
for (const row of clinicRows) {
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
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 typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
|
||||||
const treatmentTypes = catalog
|
const catalog = await this.prosthesisCatalog.list();
|
||||||
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
|
const prosthesisTypes = catalog
|
||||||
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
|
.filter((entry) => typeCodes.has(entry.code))
|
||||||
|
.map((entry) => ({ code: entry.code }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
treatmentTypes,
|
prosthesisTypes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -216,8 +216,8 @@ export class CasesService {
|
|||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
query: ListLabCasesDto,
|
query: ListLabCasesDto,
|
||||||
) {
|
) {
|
||||||
if (query.treatmentType) {
|
if (query.prosthesisTypeCode) {
|
||||||
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -240,12 +240,7 @@ export class CasesService {
|
|||||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
details: {
|
tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } },
|
||||||
include: {
|
|
||||||
detail: { select: { treatmentType: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tasks: { select: { id: true, status: true } },
|
|
||||||
},
|
},
|
||||||
orderBy: [{ sentAt: 'desc' }],
|
orderBy: [{ sentAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
@@ -516,10 +511,10 @@ export class CasesService {
|
|||||||
...(query.clinicOrganizationId
|
...(query.clinicOrganizationId
|
||||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||||
: {}),
|
: {}),
|
||||||
...(query.treatmentType
|
...(query.prosthesisTypeCode
|
||||||
? {
|
? {
|
||||||
details: {
|
tasks: {
|
||||||
some: { detail: { treatmentType: query.treatmentType } },
|
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -565,10 +560,14 @@ export class CasesService {
|
|||||||
organization: { id: string; name: string };
|
organization: { id: string; name: string };
|
||||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||||
};
|
};
|
||||||
details: Array<{ detail: { treatmentType: string } }>;
|
tasks: Array<{
|
||||||
tasks: Array<{ id: string; status: LabTaskStatus }>;
|
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;
|
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -584,7 +583,7 @@ export class CasesService {
|
|||||||
lastName: lc.treatment.patient.lastName,
|
lastName: lc.treatment.patient.lastName,
|
||||||
mobile: lc.treatment.patient.mobile,
|
mobile: lc.treatment.patient.mobile,
|
||||||
},
|
},
|
||||||
treatmentType,
|
prosthesisGroups,
|
||||||
taskProgress: {
|
taskProgress: {
|
||||||
completed: completedTasks,
|
completed: completedTasks,
|
||||||
total: lc.tasks.length,
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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(
|
private groupTasks(
|
||||||
tasks: LabCaseTaskWithRelations[],
|
tasks: LabCaseTaskWithRelations[],
|
||||||
prosthesisLabels: Map<string, string>,
|
prosthesisLabels: Map<string, string>,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ListLabCasesDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
treatmentType?: string;
|
prosthesisTypeCode?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ export class ListLabTasksDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
overdue?: boolean;
|
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()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
@@ -172,6 +183,17 @@ export class LocateTaskPageDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
overdue?: boolean;
|
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()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|||||||
@@ -337,6 +337,10 @@ export class TasksService {
|
|||||||
},
|
},
|
||||||
...(status !== undefined ? { status } : {}),
|
...(status !== undefined ? { status } : {}),
|
||||||
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
|
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
|
||||||
|
...(query.unassignedOnly ? { assigneeUserId: null } : {}),
|
||||||
|
...(query.prosthesisTypeCode?.trim()
|
||||||
|
? { prosthesisTypeCode: query.prosthesisTypeCode.trim() }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepCompleted = query.stepCompleted?.trim();
|
const stepCompleted = query.stepCompleted?.trim();
|
||||||
@@ -385,6 +389,8 @@ export class TasksService {
|
|||||||
stepCompleted: query.stepCompleted,
|
stepCompleted: query.stepCompleted,
|
||||||
assignedToMe: query.assignedToMe,
|
assignedToMe: query.assignedToMe,
|
||||||
overdue: query.overdue,
|
overdue: query.overdue,
|
||||||
|
unassignedOnly: query.unassignedOnly,
|
||||||
|
prosthesisTypeCode: query.prosthesisTypeCode,
|
||||||
sortBy: query.sortBy,
|
sortBy: query.sortBy,
|
||||||
sortDir: query.sortDir,
|
sortDir: query.sortDir,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type CatalogLocale,
|
type CatalogLocale,
|
||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
import { StaffWorkingHoursService } from '../staff/staff-working-hours.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';
|
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||||
|
|
||||||
type ChartBucket = { code: string; label: string; count: number };
|
type ChartBucket = { code: string; label: string; count: number };
|
||||||
@@ -52,6 +53,7 @@ type TodayCharts = {
|
|||||||
appointmentsWeekMine?: ChartBucket[];
|
appointmentsWeekMine?: ChartBucket[];
|
||||||
labTaskActivityWeek?: StackedDayBucket[];
|
labTaskActivityWeek?: StackedDayBucket[];
|
||||||
casePartnersMonth?: PartnerCasesBucket[];
|
casePartnersMonth?: PartnerCasesBucket[];
|
||||||
|
casesDueWeek?: ChartBucket[];
|
||||||
efficiencyReport?: ChartBucket[];
|
efficiencyReport?: ChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,9 +90,11 @@ type TodayWidgets = {
|
|||||||
casesInProgress?: { count: number };
|
casesInProgress?: { count: number };
|
||||||
tasksInProgress?: { count: number };
|
tasksInProgress?: { count: number };
|
||||||
importantTasks?: { count: number };
|
importantTasks?: { count: number };
|
||||||
|
overdueCases?: { count: number };
|
||||||
|
unassignedTasks?: { count: number };
|
||||||
pendingConnections?: { count: number };
|
pendingConnections?: { count: number };
|
||||||
pendingStaffInvites?: { count: number };
|
pendingStaffInvites?: { count: number };
|
||||||
providersWithoutWorkingHours?: { count: number };
|
providersWithoutWorkingHours?: { count: number; membershipIds: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -225,6 +229,14 @@ export class TodayService {
|
|||||||
);
|
);
|
||||||
tasks.push(this.loadCasesInProgress(organizationId, widgets));
|
tasks.push(this.loadCasesInProgress(organizationId, widgets));
|
||||||
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
||||||
|
tasks.push(
|
||||||
|
this.loadCasesDueWeek(
|
||||||
|
organizationId,
|
||||||
|
from,
|
||||||
|
query.utcOffsetMinutes,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.canEditCases(membership.isOwner, permissionNames)) {
|
if (this.canEditCases(membership.isOwner, permissionNames)) {
|
||||||
@@ -243,6 +255,8 @@ export class TodayService {
|
|||||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||||
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
||||||
tasks.push(this.loadImportantTasks(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));
|
tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +554,32 @@ export class TodayService {
|
|||||||
widgets.importantTasks = { count };
|
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) {
|
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
|
||||||
const links = await this.prisma.organizationLink.findMany({
|
const links = await this.prisma.organizationLink.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -772,17 +812,47 @@ export class TodayService {
|
|||||||
widgets.pendingStaffInvites = { count };
|
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(
|
private async loadAppointmentsByProvider(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
charts: TodayCharts,
|
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({
|
const appointments = await this.prisma.appointment.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
startAt: { lt: to },
|
startAt: { lt: to },
|
||||||
endAt: { gt: from },
|
endAt: { gt: from },
|
||||||
|
providerUserId: { in: [...participatingUserIds] },
|
||||||
},
|
},
|
||||||
select: { providerUserId: true },
|
select: { providerUserId: true },
|
||||||
});
|
});
|
||||||
@@ -795,25 +865,33 @@ export class TodayService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countsByProvider.size === 0) {
|
const rows = members.map((member) => ({
|
||||||
charts.appointmentsByProvider = [];
|
userId: member.user.id,
|
||||||
return;
|
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()]
|
charts.appointmentsByProvider = top.map((row) => ({
|
||||||
.sort((a, b) => b[1] - a[1])
|
code: row.userId,
|
||||||
.slice(0, 8);
|
label: row.label,
|
||||||
|
count: row.count,
|
||||||
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,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,21 +899,10 @@ export class TodayService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
widgets: TodayWidgets,
|
widgets: TodayWidgets,
|
||||||
) {
|
) {
|
||||||
const members = await this.prisma.membership.findMany({
|
const members = await this.listTreatmentParticipatingMembers(organizationId);
|
||||||
where: {
|
|
||||||
organizationId,
|
|
||||||
OR: [{ isOwner: true }, { isActive: true }],
|
|
||||||
permissions: {
|
|
||||||
some: {
|
|
||||||
permission: { name: 'TAB_TREATMENT_EDIT' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
widgets.providersWithoutWorkingHours = { count: 0 };
|
widgets.providersWithoutWorkingHours = { count: 0, membershipIds: [] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,12 +911,52 @@ export class TodayService {
|
|||||||
members.map((member) => member.id),
|
members.map((member) => member.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const count = members.filter((member) => {
|
const missingHoursMembers = members.filter((member) => {
|
||||||
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
|
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
|
||||||
return blocks.length === 0;
|
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) {
|
private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||||
@@ -1279,6 +1386,33 @@ function buildLastSevenLocalDayBuckets(
|
|||||||
return buckets;
|
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 {
|
function localDayKeyFromDate(date: Date, offsetMs: number): string {
|
||||||
const localMs = date.getTime() + offsetMs;
|
const localMs = date.getTime() + offsetMs;
|
||||||
const local = new Date(localMs);
|
const local = new Date(localMs);
|
||||||
|
|||||||
@@ -209,6 +209,8 @@
|
|||||||
"widgetCasesInProgress": "Cases In Progress",
|
"widgetCasesInProgress": "Cases In Progress",
|
||||||
"widgetTasksInProgress": "Tasks In Progress",
|
"widgetTasksInProgress": "Tasks In Progress",
|
||||||
"widgetImportantTasks": "Important Tasks",
|
"widgetImportantTasks": "Important Tasks",
|
||||||
|
"widgetOverdueCases": "Overdue Cases",
|
||||||
|
"widgetUnassignedTasks": "Unassigned Tasks",
|
||||||
"widgetPendingConnections": "Pending Connections",
|
"widgetPendingConnections": "Pending Connections",
|
||||||
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
"widgetPendingStaffInvites": "Pending Staff Invites",
|
||||||
@@ -243,6 +245,8 @@
|
|||||||
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
||||||
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
||||||
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
"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",
|
"chartCasePartnersClinicTitle": "Cases by Lab",
|
||||||
"chartCasePartnersLabTitle": "Cases by Clinic",
|
"chartCasePartnersLabTitle": "Cases by Clinic",
|
||||||
"chartCasePartnersSubtitle": "Last 30 days",
|
"chartCasePartnersSubtitle": "Last 30 days",
|
||||||
@@ -444,8 +448,8 @@
|
|||||||
"assignedTo": "Assigned to {name}",
|
"assignedTo": "Assigned to {name}",
|
||||||
"filterClinic": "Clinic",
|
"filterClinic": "Clinic",
|
||||||
"filterClinicAll": "All clinics",
|
"filterClinicAll": "All clinics",
|
||||||
"filterTreatmentType": "Treatment type",
|
"filterProsthesisType": "Prosthesis type",
|
||||||
"filterTreatmentTypeAll": "All types",
|
"filterProsthesisTypeAll": "All types",
|
||||||
"filterSentFrom": "Sent from",
|
"filterSentFrom": "Sent from",
|
||||||
"filterSentTo": "Sent to",
|
"filterSentTo": "Sent to",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
@@ -492,6 +496,7 @@
|
|||||||
"importantOnly": "Important first",
|
"importantOnly": "Important first",
|
||||||
"assignedToMe": "Assigned to me",
|
"assignedToMe": "Assigned to me",
|
||||||
"overdueOnly": "Overdue cases only",
|
"overdueOnly": "Overdue cases only",
|
||||||
|
"unassignedOnly": "Unassigned only",
|
||||||
"assignedToStaff": "Assigned to {name}",
|
"assignedToStaff": "Assigned to {name}",
|
||||||
"resetView": "Reset filters & sort",
|
"resetView": "Reset filters & sort",
|
||||||
"showInCase": "Show in case",
|
"showInCase": "Show in case",
|
||||||
|
|||||||
@@ -209,6 +209,8 @@
|
|||||||
"widgetCasesInProgress": "پروندههای در حال انجام",
|
"widgetCasesInProgress": "پروندههای در حال انجام",
|
||||||
"widgetTasksInProgress": "وظایف در حال انجام",
|
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||||
"widgetImportantTasks": "وظایف مهم",
|
"widgetImportantTasks": "وظایف مهم",
|
||||||
|
"widgetOverdueCases": "پروندههای معوق",
|
||||||
|
"widgetUnassignedTasks": "وظایف بدون مسئول",
|
||||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||||
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||||
@@ -243,6 +245,8 @@
|
|||||||
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||||
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||||
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||||
|
"chartCasesDueWeekTitle": "پروندههای موعددار این هفته",
|
||||||
|
"chartCasesDueWeekSubtitle": "پروندههای فعال با موعد تحویل در ۷ روز آینده",
|
||||||
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||||
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||||
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
||||||
@@ -444,8 +448,8 @@
|
|||||||
"assignedTo": "واگذار شده به {name}",
|
"assignedTo": "واگذار شده به {name}",
|
||||||
"filterClinic": "کلینیک",
|
"filterClinic": "کلینیک",
|
||||||
"filterClinicAll": "همه کلینیکها",
|
"filterClinicAll": "همه کلینیکها",
|
||||||
"filterTreatmentType": "نوع درمان",
|
"filterProsthesisType": "نوع پروتز",
|
||||||
"filterTreatmentTypeAll": "همه انواع",
|
"filterProsthesisTypeAll": "همه انواع",
|
||||||
"filterSentFrom": "ارسال از",
|
"filterSentFrom": "ارسال از",
|
||||||
"filterSentTo": "ارسال تا",
|
"filterSentTo": "ارسال تا",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
@@ -493,6 +497,7 @@
|
|||||||
"importantOnly": "مهمها در ابتدا",
|
"importantOnly": "مهمها در ابتدا",
|
||||||
"assignedToMe": "واگذار شده به من",
|
"assignedToMe": "واگذار شده به من",
|
||||||
"overdueOnly": "فقط پروندههای عقبافتاده",
|
"overdueOnly": "فقط پروندههای عقبافتاده",
|
||||||
|
"unassignedOnly": "فقط بدون مسئول",
|
||||||
"assignedToStaff": "واگذار شده به {name}",
|
"assignedToStaff": "واگذار شده به {name}",
|
||||||
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
||||||
"showInCase": "نمایش در پرونده",
|
"showInCase": "نمایش در پرونده",
|
||||||
|
|||||||
@@ -209,6 +209,8 @@
|
|||||||
"widgetCasesInProgress": "Cases in uitvoering",
|
"widgetCasesInProgress": "Cases in uitvoering",
|
||||||
"widgetTasksInProgress": "Taken in uitvoering",
|
"widgetTasksInProgress": "Taken in uitvoering",
|
||||||
"widgetImportantTasks": "Belangrijke taken",
|
"widgetImportantTasks": "Belangrijke taken",
|
||||||
|
"widgetOverdueCases": "Achterstallige cases",
|
||||||
|
"widgetUnassignedTasks": "Niet-toegewezen taken",
|
||||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||||
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
||||||
@@ -243,6 +245,8 @@
|
|||||||
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
||||||
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
||||||
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
||||||
|
"chartCasesDueWeekTitle": "Cases met deadline deze week",
|
||||||
|
"chartCasesDueWeekSubtitle": "Actieve cases met een deadline in de komende 7 dagen",
|
||||||
"chartCasePartnersClinicTitle": "Cases per lab",
|
"chartCasePartnersClinicTitle": "Cases per lab",
|
||||||
"chartCasePartnersLabTitle": "Cases per kliniek",
|
"chartCasePartnersLabTitle": "Cases per kliniek",
|
||||||
"chartCasePartnersSubtitle": "Afgelopen 30 dagen",
|
"chartCasePartnersSubtitle": "Afgelopen 30 dagen",
|
||||||
@@ -444,8 +448,8 @@
|
|||||||
"assignedTo": "Toegewezen aan {name}",
|
"assignedTo": "Toegewezen aan {name}",
|
||||||
"filterClinic": "Kliniek",
|
"filterClinic": "Kliniek",
|
||||||
"filterClinicAll": "Alle klinieken",
|
"filterClinicAll": "Alle klinieken",
|
||||||
"filterTreatmentType": "Behandeltype",
|
"filterProsthesisType": "Prothesetype",
|
||||||
"filterTreatmentTypeAll": "Alle types",
|
"filterProsthesisTypeAll": "Alle types",
|
||||||
"filterSentFrom": "Verzonden vanaf",
|
"filterSentFrom": "Verzonden vanaf",
|
||||||
"filterSentTo": "Verzonden tot",
|
"filterSentTo": "Verzonden tot",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
@@ -493,6 +497,7 @@
|
|||||||
"importantOnly": "Belangrijke cases eerst",
|
"importantOnly": "Belangrijke cases eerst",
|
||||||
"assignedToMe": "Toegewezen aan mij",
|
"assignedToMe": "Toegewezen aan mij",
|
||||||
"overdueOnly": "Alleen te late cases",
|
"overdueOnly": "Alleen te late cases",
|
||||||
|
"unassignedOnly": "Alleen niet-toegewezen",
|
||||||
"assignedToStaff": "Toegewezen aan {name}",
|
"assignedToStaff": "Toegewezen aan {name}",
|
||||||
"resetView": "Filters en sortering resetten",
|
"resetView": "Filters en sortering resetten",
|
||||||
"showInCase": "In case tonen",
|
"showInCase": "In case tonen",
|
||||||
|
|||||||
54
frontend/src/components/lab/parseTasksSearchParams.ts
Normal file
54
frontend/src/components/lab/parseTasksSearchParams.ts
Normal file
@@ -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<TasksViewState> {
|
||||||
|
const partial: Partial<TasksViewState> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export type TasksViewState = {
|
|||||||
importantOnly: boolean;
|
importantOnly: boolean;
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
overdueOnly: boolean;
|
overdueOnly: boolean;
|
||||||
|
unassignedOnly: boolean;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
page: number;
|
page: number;
|
||||||
highlightTaskId: string | null;
|
highlightTaskId: string | null;
|
||||||
};
|
};
|
||||||
@@ -24,6 +26,8 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
|||||||
importantOnly: false,
|
importantOnly: false,
|
||||||
assignedToMe: false,
|
assignedToMe: false,
|
||||||
overdueOnly: false,
|
overdueOnly: false,
|
||||||
|
unassignedOnly: false,
|
||||||
|
prosthesisTypeCode: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
highlightTaskId: null,
|
highlightTaskId: null,
|
||||||
};
|
};
|
||||||
@@ -41,6 +45,8 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
|
|||||||
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
||||||
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
||||||
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
|
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.page === DEFAULT_TASKS_VIEW.page &&
|
||||||
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||||
);
|
);
|
||||||
|
|||||||
20
frontend/src/components/staff/staffRowHighlight.ts
Normal file
20
frontend/src/components/staff/staffRowHighlight.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string>,
|
||||||
|
): boolean {
|
||||||
|
return highlightIds.has(membershipId);
|
||||||
|
}
|
||||||
28
frontend/src/components/today/today-deep-links.ts
Normal file
28
frontend/src/components/today/today-deep-links.ts
Normal file
@@ -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<Record<TodayWidgetKey, string>> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ export const TODAY_KPI_GADGET_FEATURE: Record<TodayWidgetKey, TodayGadgetFeature
|
|||||||
casesInProgress: 'cases',
|
casesInProgress: 'cases',
|
||||||
tasksInProgress: 'tasks',
|
tasksInProgress: 'tasks',
|
||||||
importantTasks: 'tasks',
|
importantTasks: 'tasks',
|
||||||
|
overdueCases: 'tasks',
|
||||||
|
unassignedTasks: 'tasks',
|
||||||
pendingConnections: 'organizations',
|
pendingConnections: 'organizations',
|
||||||
pendingStaffInvites: 'staff',
|
pendingStaffInvites: 'staff',
|
||||||
};
|
};
|
||||||
@@ -53,6 +55,7 @@ export const TODAY_GADGET_ID_FEATURE: Record<string, TodayGadgetFeature> = {
|
|||||||
'chart-treatment-mix': 'treatment',
|
'chart-treatment-mix': 'treatment',
|
||||||
'chart-lab-task-activity': 'cases',
|
'chart-lab-task-activity': 'cases',
|
||||||
'chart-tasks-by-prosthesis': 'tasks',
|
'chart-tasks-by-prosthesis': 'tasks',
|
||||||
|
'chart-cases-due-week': 'cases',
|
||||||
'chart-case-partners-month': 'treatment',
|
'chart-case-partners-month': 'treatment',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Link2,
|
Link2,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
UserRound,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
type OrgTypeName,
|
type OrgTypeName,
|
||||||
} from '@/components/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
|
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';
|
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ export interface TodayKpiDefinition {
|
|||||||
color: KpiCardColor;
|
color: KpiCardColor;
|
||||||
orgTypes: OrgTypeName[];
|
orgTypes: OrgTypeName[];
|
||||||
href: string;
|
href: string;
|
||||||
|
/** When set, overrides static href using live widget payload (e.g. membership IDs). */
|
||||||
|
resolveHref?: (widgets: TodaySummaryWidgets) => string;
|
||||||
isVisible: (org: Organization | null) => boolean;
|
isVisible: (org: Organization | null) => boolean;
|
||||||
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
||||||
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
||||||
@@ -119,6 +124,13 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
orgTypes: ['CLINIC'],
|
orgTypes: ['CLINIC'],
|
||||||
href: '/staff',
|
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),
|
isVisible: (org) => canViewStaff(org),
|
||||||
formatValue: (widgets) => {
|
formatValue: (widgets) => {
|
||||||
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
||||||
@@ -157,7 +169,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
orgTypes: ['LAB'],
|
orgTypes: ['LAB'],
|
||||||
href: '/tasks',
|
href: todayKpiHref('tasksInProgress', '/tasks'),
|
||||||
isVisible: (org) => canViewTasks(org),
|
isVisible: (org) => canViewTasks(org),
|
||||||
formatValue: (widgets) => {
|
formatValue: (widgets) => {
|
||||||
const count = countWidget(widgets, 'tasksInProgress');
|
const count = countWidget(widgets, 'tasksInProgress');
|
||||||
@@ -170,13 +182,39 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
orgTypes: ['LAB'],
|
orgTypes: ['LAB'],
|
||||||
href: '/tasks',
|
href: todayKpiHref('importantTasks', '/tasks'),
|
||||||
isVisible: (org) => canViewTasks(org),
|
isVisible: (org) => canViewTasks(org),
|
||||||
formatValue: (widgets) => {
|
formatValue: (widgets) => {
|
||||||
const count = countWidget(widgets, 'importantTasks');
|
const count = countWidget(widgets, 'importantTasks');
|
||||||
return count === null ? null : String(count);
|
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',
|
key: 'pendingConnections',
|
||||||
titleKey: 'widgetPendingConnections',
|
titleKey: 'widgetPendingConnections',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth';
|
|||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
||||||
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||||
|
import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import {
|
import {
|
||||||
formatCaseDateTime,
|
formatCaseDateTime,
|
||||||
@@ -18,6 +19,7 @@ import { notificationsApi } from '@/lib/api/notifications';
|
|||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
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 { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
AssignableTaskStaff,
|
AssignableTaskStaff,
|
||||||
CasesFilterOptions,
|
CasesFilterOptions,
|
||||||
@@ -48,7 +50,7 @@ export function CasesPage() {
|
|||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [clinicId, setClinicId] = useState('');
|
const [clinicId, setClinicId] = useState('');
|
||||||
const [treatmentType, setTreatmentType] = useState('');
|
const [prosthesisTypeCode, setProsthesisTypeCode] = useState('');
|
||||||
const [sentFrom, setSentFrom] = useState('');
|
const [sentFrom, setSentFrom] = useState('');
|
||||||
const [sentTo, setSentTo] = useState('');
|
const [sentTo, setSentTo] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -62,8 +64,9 @@ export function CasesPage() {
|
|||||||
});
|
});
|
||||||
const [filterOptions, setFilterOptions] = useState<CasesFilterOptions>({
|
const [filterOptions, setFilterOptions] = useState<CasesFilterOptions>({
|
||||||
clinics: [],
|
clinics: [],
|
||||||
treatmentTypes: [],
|
prosthesisTypes: [],
|
||||||
});
|
});
|
||||||
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
@@ -80,7 +83,13 @@ export function CasesPage() {
|
|||||||
const canEditComments = canEditTasks(currentOrganization);
|
const canEditComments = canEditTasks(currentOrganization);
|
||||||
const locale = user?.language ?? 'en';
|
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),
|
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
|
||||||
[treatmentCatalog],
|
[treatmentCatalog],
|
||||||
);
|
);
|
||||||
@@ -94,13 +103,13 @@ export function CasesPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasActiveFilters = Boolean(
|
const hasActiveFilters = Boolean(
|
||||||
search.trim() || clinicId || treatmentType || sentFrom || sentTo,
|
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadCases = async (params: {
|
const loadCases = async (params: {
|
||||||
q: string;
|
q: string;
|
||||||
clinicOrganizationId: string;
|
clinicOrganizationId: string;
|
||||||
treatmentType: string;
|
prosthesisTypeCode: string;
|
||||||
sentFrom: string;
|
sentFrom: string;
|
||||||
sentTo: string;
|
sentTo: string;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -111,7 +120,7 @@ export function CasesPage() {
|
|||||||
const response = await casesApi.list({
|
const response = await casesApi.list({
|
||||||
q: params.q.trim() || undefined,
|
q: params.q.trim() || undefined,
|
||||||
clinicOrganizationId: params.clinicOrganizationId || undefined,
|
clinicOrganizationId: params.clinicOrganizationId || undefined,
|
||||||
treatmentType: params.treatmentType || undefined,
|
prosthesisTypeCode: params.prosthesisTypeCode || undefined,
|
||||||
sentFrom: params.sentFrom || undefined,
|
sentFrom: params.sentFrom || undefined,
|
||||||
sentTo: params.sentTo || undefined,
|
sentTo: params.sentTo || undefined,
|
||||||
page: params.page,
|
page: params.page,
|
||||||
@@ -148,6 +157,7 @@ export function CasesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
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(() => {});
|
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {});
|
void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {});
|
||||||
@@ -156,11 +166,6 @@ export function CasesPage() {
|
|||||||
}, [canEdit]);
|
}, [canEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const caseIdFromUrl = searchParams.get('caseId');
|
|
||||||
if (caseIdFromUrl) {
|
|
||||||
setSelectedCaseId(caseIdFromUrl);
|
|
||||||
setMobileDetailOpen(true);
|
|
||||||
}
|
|
||||||
const clinicFromUrl = searchParams.get('clinicOrganizationId');
|
const clinicFromUrl = searchParams.get('clinicOrganizationId');
|
||||||
if (clinicFromUrl) {
|
if (clinicFromUrl) {
|
||||||
setClinicId(clinicFromUrl);
|
setClinicId(clinicFromUrl);
|
||||||
@@ -168,17 +173,37 @@ export function CasesPage() {
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCaseId) {
|
if (loadingList) return;
|
||||||
setMobileDetailOpen(false);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void loadCases({
|
void loadCases({
|
||||||
q: search,
|
q: search,
|
||||||
clinicOrganizationId: clinicId,
|
clinicOrganizationId: clinicId,
|
||||||
treatmentType,
|
prosthesisTypeCode,
|
||||||
sentFrom,
|
sentFrom,
|
||||||
sentTo,
|
sentTo,
|
||||||
page,
|
page,
|
||||||
@@ -186,7 +211,13 @@ export function CasesPage() {
|
|||||||
}, search ? 300 : 0);
|
}, search ? 300 : 0);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (selectedCaseId) {
|
if (selectedCaseId) {
|
||||||
@@ -222,7 +253,7 @@ export function CasesPage() {
|
|||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setClinicId('');
|
setClinicId('');
|
||||||
setTreatmentType('');
|
setProsthesisTypeCode('');
|
||||||
setSentFrom('');
|
setSentFrom('');
|
||||||
setSentTo('');
|
setSentTo('');
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -314,19 +345,19 @@ export function CasesPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs font-medium text-text-muted">{t('filterTreatmentType')}</span>
|
<span className="text-xs font-medium text-text-muted">{t('filterProsthesisType')}</span>
|
||||||
<select
|
<select
|
||||||
value={treatmentType}
|
value={prosthesisTypeCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTreatmentType(e.target.value);
|
setProsthesisTypeCode(e.target.value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
>
|
>
|
||||||
<option value="">{t('filterTreatmentTypeAll')}</option>
|
<option value="">{t('filterProsthesisTypeAll')}</option>
|
||||||
{filterOptions.treatmentTypes.map((type) => (
|
{filterOptions.prosthesisTypes.map((type) => (
|
||||||
<option key={type.code} value={type.code}>
|
<option key={type.code} value={type.code}>
|
||||||
{treatmentLabel(type.code)}
|
{prosthesisLabel(type.code)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -410,17 +441,17 @@ export function CasesPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-0.5">
|
|
||||||
{item.patient.mobile}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||||
<div className="text-xs text-text-muted mt-1">
|
<div className="mt-1">
|
||||||
|
<LabCaseProsthesisGroupsList
|
||||||
|
groups={item.prosthesisGroups}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-text-muted mt-1">
|
||||||
{formatCaseDateTime(item.sentAt, locale)}
|
{formatCaseDateTime(item.sentAt, locale)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-1 truncate">
|
<div className="mt-1.5">
|
||||||
{item.treatmentType ? treatmentLabel(item.treatmentType) : '—'}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<CaseTaskProgressBar
|
<CaseTaskProgressBar
|
||||||
completed={item.taskProgress.completed}
|
completed={item.taskProgress.completed}
|
||||||
total={item.taskProgress.total}
|
total={item.taskProgress.total}
|
||||||
@@ -471,15 +502,15 @@ export function CasesPage() {
|
|||||||
{mobileDetailOpen && selectedCaseId ? (
|
{mobileDetailOpen && selectedCaseId ? (
|
||||||
<MobileDetailBackButton onClick={() => setMobileDetailOpen(false)} />
|
<MobileDetailBackButton onClick={() => setMobileDetailOpen(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
{!selectedCaseId ? (
|
{!selectedCaseId && !loadingList && cases.length === 0 ? (
|
||||||
<p className="text-sm text-text-muted">{t('selectCaseHint')}</p>
|
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
||||||
) : loadingDetail || !selectedCase ? (
|
) : loadingDetail || !selectedCase ? (
|
||||||
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<CaseDetailPanel
|
<CaseDetailPanel
|
||||||
labCase={selectedCase}
|
labCase={selectedCase}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
treatmentLabel={treatmentLabel}
|
treatmentLabel={treatmentDetailLabel}
|
||||||
statusOptions={statusOptions}
|
statusOptions={statusOptions}
|
||||||
loadAttachmentBlob={loadCaseAttachmentBlob}
|
loadAttachmentBlob={loadCaseAttachmentBlob}
|
||||||
showCommentsButton
|
showCommentsButton
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatToothList,
|
||||||
|
prosthesisTypeColorFromCatalog,
|
||||||
|
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
|
export type LabCaseProsthesisGroup = {
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
teeth: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LabCaseProsthesisGroupsListProps {
|
||||||
|
groups: LabCaseProsthesisGroup[];
|
||||||
|
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||||
|
fallbackTeeth?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
|
||||||
|
return catalog.find((entry) => entry.code === code)?.label ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabCaseProsthesisGroupsList({
|
||||||
|
groups,
|
||||||
|
prosthesisCatalog,
|
||||||
|
fallbackTeeth = [],
|
||||||
|
}: LabCaseProsthesisGroupsListProps) {
|
||||||
|
if (groups.length > 0) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<li
|
||||||
|
key={group.prosthesisTypeCode}
|
||||||
|
className="text-[11px] leading-snug"
|
||||||
|
style={{
|
||||||
|
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||||
|
</span>
|
||||||
|
{group.teeth.length > 0 ? (
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{' · '}
|
||||||
|
{formatToothList(group.teeth)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackTeeth.length > 0) {
|
||||||
|
return <p className="text-[11px] text-text-muted">{formatToothList(fallbackTeeth)}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
isDefaultTasksView,
|
isDefaultTasksView,
|
||||||
TASK_COMPLETE_EXIT_MS,
|
TASK_COMPLETE_EXIT_MS,
|
||||||
} from '@/components/lab/tasksViewDefaults';
|
} from '@/components/lab/tasksViewDefaults';
|
||||||
|
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
|
||||||
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
@@ -40,6 +42,7 @@ const PAGE_SIZE = 50;
|
|||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
const t = useTranslations('tasks');
|
const t = useTranslations('tasks');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { currentOrganization, user, isAuthReady } = useAuth();
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||||
const { showError, showSuccess, setError } = useToast();
|
const { showError, showSuccess, setError } = useToast();
|
||||||
|
|
||||||
@@ -71,6 +74,10 @@ export function TasksPage() {
|
|||||||
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
|
const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
const [prosthesisTypeCode, setProsthesisTypeCode] = useState(
|
||||||
|
DEFAULT_TASKS_VIEW.prosthesisTypeCode,
|
||||||
|
);
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
||||||
@@ -108,8 +115,10 @@ export function TasksPage() {
|
|||||||
if (importantOnly) params.pinImportant = true;
|
if (importantOnly) params.pinImportant = true;
|
||||||
if (assignedToMe) params.assignedToMe = true;
|
if (assignedToMe) params.assignedToMe = true;
|
||||||
if (overdueOnly) params.overdue = true;
|
if (overdueOnly) params.overdue = true;
|
||||||
|
if (unassignedOnly) params.unassignedOnly = true;
|
||||||
|
if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, prosthesisTypeCode, sortBy, sortDir]);
|
||||||
|
|
||||||
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||||
|
|
||||||
@@ -126,6 +135,8 @@ export function TasksPage() {
|
|||||||
importantOnly,
|
importantOnly,
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
overdueOnly,
|
overdueOnly,
|
||||||
|
unassignedOnly,
|
||||||
|
prosthesisTypeCode,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
}),
|
}),
|
||||||
@@ -139,6 +150,8 @@ export function TasksPage() {
|
|||||||
importantOnly,
|
importantOnly,
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
overdueOnly,
|
overdueOnly,
|
||||||
|
unassignedOnly,
|
||||||
|
prosthesisTypeCode,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
],
|
],
|
||||||
@@ -146,6 +159,18 @@ export function TasksPage() {
|
|||||||
|
|
||||||
const showReset = !isDefaultTasksView(viewState);
|
const showReset = !isDefaultTasksView(viewState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromUrl = parseTasksSearchParams(searchParams);
|
||||||
|
if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly);
|
||||||
|
if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly);
|
||||||
|
if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly);
|
||||||
|
if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter);
|
||||||
|
if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode);
|
||||||
|
if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy);
|
||||||
|
if (fromUrl.sortDir !== undefined) setSortDir(fromUrl.sortDir);
|
||||||
|
setPage(1);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -205,6 +230,8 @@ export function TasksPage() {
|
|||||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
|
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
setPage(DEFAULT_TASKS_VIEW.page);
|
setPage(DEFAULT_TASKS_VIEW.page);
|
||||||
@@ -223,6 +250,8 @@ export function TasksPage() {
|
|||||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
|
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
setExpandedCommentsTaskId(null);
|
setExpandedCommentsTaskId(null);
|
||||||
@@ -462,6 +491,12 @@ export function TasksPage() {
|
|||||||
label={t('overdueOnly')}
|
label={t('overdueOnly')}
|
||||||
className="text-xs [&_span:last-child]:text-xs"
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={unassignedOnly}
|
||||||
|
onChange={(checked) => applyFilterChange(() => setUnassignedOnly(checked))}
|
||||||
|
label={t('unassignedOnly')}
|
||||||
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
|
/>
|
||||||
{showReset ? (
|
{showReset ? (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
||||||
{t('resetView')}
|
{t('resetView')}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Check, Copy, Pencil, Trash2, UserCheck, UserX } from 'lucide-react';
|
|||||||
import type { StaffMemberDto } from '@/lib/api/staff';
|
import type { StaffMemberDto } from '@/lib/api/staff';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
|
import { STAFF_ROW_HIGHLIGHT_CLASS } from '@/components/staff/staffRowHighlight';
|
||||||
|
|
||||||
type StaffMembersMobileListProps = {
|
type StaffMembersMobileListProps = {
|
||||||
members: StaffMemberDto[];
|
members: StaffMemberDto[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
organizationType: string | undefined;
|
organizationType: string | undefined;
|
||||||
|
highlightMembershipIds?: Set<string>;
|
||||||
copiedInviteMembershipId: string | null;
|
copiedInviteMembershipId: string | null;
|
||||||
copyingInviteMembershipId: string | null;
|
copyingInviteMembershipId: string | null;
|
||||||
enablingMembershipId: string | null;
|
enablingMembershipId: string | null;
|
||||||
@@ -57,6 +59,7 @@ function memberStatusBadge(
|
|||||||
export function StaffMembersMobileList({
|
export function StaffMembersMobileList({
|
||||||
members,
|
members,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
highlightMembershipIds,
|
||||||
copiedInviteMembershipId,
|
copiedInviteMembershipId,
|
||||||
copyingInviteMembershipId,
|
copyingInviteMembershipId,
|
||||||
enablingMembershipId,
|
enablingMembershipId,
|
||||||
@@ -74,9 +77,14 @@ export function StaffMembersMobileList({
|
|||||||
}: StaffMembersMobileListProps) {
|
}: StaffMembersMobileListProps) {
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-3 lg:hidden">
|
<ul className="space-y-3 lg:hidden">
|
||||||
{members.map((member) => (
|
{members.map((member) => {
|
||||||
<li key={member.id}>
|
const highlighted = highlightMembershipIds?.has(member.id) ?? false;
|
||||||
<Card padding="sm" className="space-y-3">
|
return (
|
||||||
|
<li key={member.id} id={`staff-row-${member.id}`}>
|
||||||
|
<Card
|
||||||
|
padding="sm"
|
||||||
|
className={highlighted ? `space-y-3 ${STAFF_ROW_HIGHLIGHT_CLASS}` : 'space-y-3'}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-text-primary truncate">{member.name}</p>
|
<p className="font-medium text-text-primary truncate">{member.name}</p>
|
||||||
@@ -184,7 +192,8 @@ export function StaffMembersMobileList({
|
|||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,11 @@ import { Table } from '@/components/ui/shared/Table';
|
|||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
|
import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
import {
|
||||||
|
parseHighlightMembershipIds,
|
||||||
|
STAFF_ROW_HIGHLIGHT_CLASS,
|
||||||
|
} from '@/components/staff/staffRowHighlight';
|
||||||
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
|
|
||||||
type StoredInviteLink = {
|
type StoredInviteLink = {
|
||||||
membershipId: string;
|
membershipId: string;
|
||||||
@@ -145,6 +151,7 @@ function PermissionGrid({
|
|||||||
|
|
||||||
export function StaffPage() {
|
export function StaffPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations('staff');
|
const t = useTranslations('staff');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -199,6 +206,10 @@ export function StaffPage() {
|
|||||||
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||||
|
const highlightMembershipIds = useMemo(
|
||||||
|
() => parseHighlightMembershipIds(searchParams),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
const inviteHasTreatmentEdit = useMemo(
|
const inviteHasTreatmentEdit = useMemo(
|
||||||
() =>
|
() =>
|
||||||
currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms),
|
currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms),
|
||||||
@@ -267,6 +278,18 @@ export function StaffPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || highlightMembershipIds.size === 0) return;
|
||||||
|
const firstMatch = members.find((member) => highlightMembershipIds.has(member.id));
|
||||||
|
if (!firstMatch) return;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
scrollWithinMainScrollContainer(
|
||||||
|
document.getElementById(`staff-row-${firstMatch.id}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [loading, members, highlightMembershipIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentOrganization) return;
|
if (!currentOrganization) return;
|
||||||
if (!canViewStaff(currentOrganization)) {
|
if (!canViewStaff(currentOrganization)) {
|
||||||
@@ -623,6 +646,7 @@ export function StaffPage() {
|
|||||||
members={members}
|
members={members}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
organizationType={currentOrganization?.type}
|
organizationType={currentOrganization?.type}
|
||||||
|
highlightMembershipIds={highlightMembershipIds}
|
||||||
copiedInviteMembershipId={copiedInviteMembershipId}
|
copiedInviteMembershipId={copiedInviteMembershipId}
|
||||||
copyingInviteMembershipId={copyingInviteMembershipId}
|
copyingInviteMembershipId={copyingInviteMembershipId}
|
||||||
enablingMembershipId={enablingMembershipId}
|
enablingMembershipId={enablingMembershipId}
|
||||||
@@ -669,8 +693,19 @@ export function StaffPage() {
|
|||||||
}
|
}
|
||||||
body={
|
body={
|
||||||
<>
|
<>
|
||||||
{members.map((m) => (
|
{members.map((m) => {
|
||||||
<tr key={m.id} className="hover:bg-background-secondary/45">
|
const highlighted = highlightMembershipIds.has(m.id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={m.id}
|
||||||
|
id={`staff-row-${m.id}`}
|
||||||
|
className={[
|
||||||
|
'hover:bg-background-secondary/45',
|
||||||
|
highlighted ? STAFF_ROW_HIGHLIGHT_CLASS : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
<td className="text-sm text-text-primary">{m.name}</td>
|
<td className="text-sm text-text-primary">{m.name}</td>
|
||||||
<td className="text-sm text-text-secondary">{m.email}</td>
|
<td className="text-sm text-text-secondary">{m.email}</td>
|
||||||
<td className="text-sm">
|
<td className="text-sm">
|
||||||
@@ -794,7 +829,8 @@ export function StaffPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { TodayChartBucket } from '@/types/today';
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame';
|
import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame';
|
||||||
|
import { TodayBarChartTooltip } from '@/components/ui/today/TodayBarChartTooltip';
|
||||||
import {
|
import {
|
||||||
TODAY_CHART_AXIS_COLOR,
|
TODAY_CHART_AXIS_COLOR,
|
||||||
TODAY_CHART_COLORS,
|
TODAY_CHART_COLORS,
|
||||||
@@ -23,9 +24,10 @@ import {
|
|||||||
interface TodayBarChartProps {
|
interface TodayBarChartProps {
|
||||||
data: TodayChartBucket[];
|
data: TodayChartBucket[];
|
||||||
colorForCode?: (code: string, index: number) => string;
|
colorForCode?: (code: string, index: number) => string;
|
||||||
|
onBarClick?: (bucket: TodayChartBucket) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartProps) {
|
||||||
const chartData = data.map((item) => ({
|
const chartData = data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
shortLabel: truncateLabel(item.label),
|
shortLabel: truncateLabel(item.label),
|
||||||
@@ -83,17 +85,30 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||||
contentStyle={{
|
content={
|
||||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
colorForCode ? (
|
||||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
<TodayBarChartTooltip colorForCode={colorForCode} chartData={chartData} />
|
||||||
borderRadius: '6px',
|
) : undefined
|
||||||
color: '#f5f9ff',
|
}
|
||||||
fontSize: '12px',
|
contentStyle={
|
||||||
}}
|
colorForCode
|
||||||
labelFormatter={(_, payload) => {
|
? undefined
|
||||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
: {
|
||||||
return row?.label ?? '';
|
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||||
}}
|
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#f5f9ff',
|
||||||
|
fontSize: '12px',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelFormatter={
|
||||||
|
colorForCode
|
||||||
|
? undefined
|
||||||
|
: (_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||||
|
return row?.label ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
|
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
|
||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
@@ -103,6 +118,8 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
|||||||
colorForCode?.(entry.code, index) ??
|
colorForCode?.(entry.code, index) ??
|
||||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
||||||
}
|
}
|
||||||
|
className={onBarClick ? 'cursor-pointer' : undefined}
|
||||||
|
onClick={() => onBarClick?.(entry)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|||||||
49
frontend/src/components/ui/today/TodayBarChartTooltip.tsx
Normal file
49
frontend/src/components/ui/today/TodayBarChartTooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_TOOLTIP_BG,
|
||||||
|
TODAY_CHART_TOOLTIP_BORDER,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
type TodayBarChartTooltipProps = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: ReadonlyArray<{ payload?: TodayChartBucket }>;
|
||||||
|
colorForCode?: (code: string, index: number) => string;
|
||||||
|
chartData: TodayChartBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TodayBarChartTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
colorForCode,
|
||||||
|
chartData,
|
||||||
|
}: TodayBarChartTooltipProps) {
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = payload[0]?.payload as TodayChartBucket | undefined;
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = chartData.findIndex((entry) => entry.code === row.code);
|
||||||
|
const typeColor =
|
||||||
|
colorForCode?.(row.code, index >= 0 ? index : 0) ?? '#f5f9ff';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-md px-3 py-2 text-xs shadow-md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||||
|
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-medium" style={{ color: typeColor }}>
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 font-semibold text-white">{row.count}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
import {
|
||||||
canEditCases,
|
canEditCases,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
type TodayDashboardCell,
|
type TodayDashboardCell,
|
||||||
} from '@/components/today/today-dashboard-layout';
|
} from '@/components/today/today-dashboard-layout';
|
||||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||||
|
import { todayDeepLinks } from '@/components/today/today-deep-links';
|
||||||
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||||
@@ -75,6 +77,7 @@ export function TodayDashboard({
|
|||||||
hasError = false,
|
hasError = false,
|
||||||
}: TodayDashboardProps) {
|
}: TodayDashboardProps) {
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
|
const router = useRouter();
|
||||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const orgType = currentOrganization?.type;
|
const orgType = currentOrganization?.type;
|
||||||
@@ -171,6 +174,15 @@ export function TodayDashboard({
|
|||||||
isOwner,
|
isOwner,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
prosthesisCatalog,
|
prosthesisCatalog,
|
||||||
|
onTasksProsthesisClick: (code: string) => {
|
||||||
|
router.push(todayDeepLinks.tasksByProsthesis(code));
|
||||||
|
},
|
||||||
|
onCasePartnerClick:
|
||||||
|
orgType === 'LAB'
|
||||||
|
? (code: string) => {
|
||||||
|
router.push(todayDeepLinks.casesByClinic(code));
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -190,6 +202,7 @@ export function TodayDashboard({
|
|||||||
subscription,
|
subscription,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
prosthesisCatalog,
|
prosthesisCatalog,
|
||||||
|
router,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (hasError && !loading && cells.length === 0) {
|
if (hasError && !loading && cells.length === 0) {
|
||||||
@@ -311,6 +324,8 @@ function buildDashboardCells(options: {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
|
onTasksProsthesisClick?: (code: string) => void;
|
||||||
|
onCasePartnerClick?: (code: string) => void;
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const cells: TodayDashboardCell[] = [];
|
const cells: TodayDashboardCell[] = [];
|
||||||
|
|
||||||
@@ -333,6 +348,8 @@ function buildDashboardCells(options: {
|
|||||||
canEditCases(options.currentOrganization))),
|
canEditCases(options.currentOrganization))),
|
||||||
dayLabelFormatter: options.dayLabelFormatter,
|
dayLabelFormatter: options.dayLabelFormatter,
|
||||||
prosthesisCatalog: options.prosthesisCatalog,
|
prosthesisCatalog: options.prosthesisCatalog,
|
||||||
|
onTasksProsthesisClick: options.onTasksProsthesisClick,
|
||||||
|
onCasePartnerClick: options.onCasePartnerClick,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,7 +419,7 @@ function buildDashboardCells(options: {
|
|||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
icon={definition.icon}
|
icon={definition.icon}
|
||||||
color={definition.color}
|
color={definition.color}
|
||||||
href={definition.href}
|
href={definition.resolveHref?.(options.widgets) ?? definition.href}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -421,6 +438,8 @@ function buildChartCells(options: {
|
|||||||
showCasePartnersChart: boolean;
|
showCasePartnersChart: boolean;
|
||||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
|
onTasksProsthesisClick?: (code: string) => void;
|
||||||
|
onCasePartnerClick?: (code: string) => void;
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||||
const cells: TodayDashboardCell[] = [];
|
const cells: TodayDashboardCell[] = [];
|
||||||
@@ -597,12 +616,38 @@ function buildChartCells(options: {
|
|||||||
<TodayBarChart
|
<TodayBarChart
|
||||||
data={tasksByProsthesisData}
|
data={tasksByProsthesisData}
|
||||||
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
|
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
|
||||||
|
onBarClick={
|
||||||
|
options.onTasksProsthesisClick
|
||||||
|
? (bucket) => options.onTasksProsthesisClick?.(bucket.code)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const casesDueWeekData = mapWeekChartBuckets(
|
||||||
|
charts.casesDueWeek ?? [],
|
||||||
|
options.dayLabelFormatter,
|
||||||
|
);
|
||||||
|
if (orgType === 'LAB' && charts.casesDueWeek !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-cases-due-week',
|
||||||
|
layout: barChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartCasesDueWeekTitle')}
|
||||||
|
subtitle={t('chartCasesDueWeekSubtitle')}
|
||||||
|
isEmpty={casesDueWeekData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayAreaChart data={casesDueWeekData} />
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const casePartnersData = charts.casePartnersMonth ?? [];
|
const casePartnersData = charts.casePartnersMonth ?? [];
|
||||||
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
|
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
|
||||||
cells.push({
|
cells.push({
|
||||||
@@ -629,6 +674,11 @@ function buildChartCells(options: {
|
|||||||
? t('chartCasePartnersSentLegend')
|
? t('chartCasePartnersSentLegend')
|
||||||
: t('chartCasePartnersOpenLegend')
|
: t('chartCasePartnersOpenLegend')
|
||||||
}
|
}
|
||||||
|
onPartnerClick={
|
||||||
|
options.onCasePartnerClick
|
||||||
|
? (partner) => options.onCasePartnerClick?.(partner.code)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
),
|
),
|
||||||
@@ -657,6 +707,7 @@ function countVisibleCharts(
|
|||||||
if (orgType === 'LAB') {
|
if (orgType === 'LAB') {
|
||||||
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||||
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
||||||
|
count += charts.casesDueWeek !== undefined ? 1 : 0;
|
||||||
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -23,18 +23,26 @@ interface TodayPartnerCasesStackedBarChartProps {
|
|||||||
data: TodayPartnerCasesBucket[];
|
data: TodayPartnerCasesBucket[];
|
||||||
completedLabel: string;
|
completedLabel: string;
|
||||||
pendingLabel: string;
|
pendingLabel: string;
|
||||||
|
onPartnerClick?: (partner: TodayPartnerCasesBucket) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayPartnerCasesStackedBarChart({
|
export function TodayPartnerCasesStackedBarChart({
|
||||||
data,
|
data,
|
||||||
completedLabel,
|
completedLabel,
|
||||||
pendingLabel,
|
pendingLabel,
|
||||||
|
onPartnerClick,
|
||||||
}: TodayPartnerCasesStackedBarChartProps) {
|
}: TodayPartnerCasesStackedBarChartProps) {
|
||||||
const chartData = data.map((item) => ({
|
const chartData = data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
shortLabel: truncateLabel(item.label),
|
shortLabel: truncateLabel(item.label),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const handlePartnerClick = (payload: { code?: string } | undefined) => {
|
||||||
|
if (!onPartnerClick || !payload?.code) return;
|
||||||
|
const partner = chartData.find((row) => row.code === payload.code);
|
||||||
|
if (partner) onPartnerClick(partner);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TodayChartFrame>
|
<TodayChartFrame>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
@@ -71,6 +79,8 @@ export function TodayPartnerCasesStackedBarChart({
|
|||||||
fill={TODAY_CHART_COMPLETED_COLOR}
|
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||||
radius={[0, 0, 0, 0]}
|
radius={[0, 0, 0, 0]}
|
||||||
maxBarSize={48}
|
maxBarSize={48}
|
||||||
|
className={onPartnerClick ? 'cursor-pointer' : undefined}
|
||||||
|
onClick={(payload) => handlePartnerClick(payload as { code?: string })}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="pending"
|
dataKey="pending"
|
||||||
@@ -79,6 +89,8 @@ export function TodayPartnerCasesStackedBarChart({
|
|||||||
fill={TODAY_CHART_RECEIVED_COLOR}
|
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
maxBarSize={48}
|
maxBarSize={48}
|
||||||
|
className={onPartnerClick ? 'cursor-pointer' : undefined}
|
||||||
|
onClick={(payload) => handlePartnerClick(payload as { code?: string })}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
import { CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel';
|
||||||
|
import { LabCaseProsthesisGroupsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import { formatCaseDateTime } from '@/components/lab/caseDetailUtils';
|
import { formatCaseDateTime } from '@/components/lab/caseDetailUtils';
|
||||||
import {
|
|
||||||
formatToothList,
|
|
||||||
prosthesisTypeColorFromCatalog,
|
|
||||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
|
||||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||||
|
|
||||||
@@ -28,10 +25,6 @@ interface TreatmentLabCasesPanelProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prosthesisLabel(code: string, catalog: ProsthesisCatalogEntry[]): string {
|
|
||||||
return catalog.find((entry) => entry.code === code)?.label ?? code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TreatmentLabCasesPanel({
|
export function TreatmentLabCasesPanel({
|
||||||
scope,
|
scope,
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
@@ -138,33 +131,12 @@ export function TreatmentLabCasesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.prosthesisGroups.length > 0 ? (
|
{item.prosthesisGroups.length > 0 || item.teeth.length > 0 ? (
|
||||||
<ul className="space-y-0.5">
|
<LabCaseProsthesisGroupsList
|
||||||
{item.prosthesisGroups.map((group) => (
|
groups={item.prosthesisGroups}
|
||||||
<li
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
key={group.prosthesisTypeCode}
|
fallbackTeeth={item.teeth}
|
||||||
className="text-[11px] leading-snug"
|
/>
|
||||||
style={{
|
|
||||||
color: prosthesisTypeColorFromCatalog(
|
|
||||||
group.prosthesisTypeCode,
|
|
||||||
prosthesisCatalog,
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-medium">
|
|
||||||
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
|
||||||
</span>
|
|
||||||
{group.teeth.length > 0 ? (
|
|
||||||
<span className="text-text-muted">
|
|
||||||
{' · '}
|
|
||||||
{formatToothList(group.teeth)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : item.teeth.length > 0 ? (
|
|
||||||
<p className="text-[11px] text-text-muted">{formatToothList(item.teeth)}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<p className="text-[10px] text-text-muted">
|
<p className="text-[10px] text-text-muted">
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
||||||
|
|
||||||
|
export interface LabCaseProsthesisGroup {
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
teeth: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LabCaseListItem {
|
export interface LabCaseListItem {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
@@ -14,7 +19,7 @@ export interface LabCaseListItem {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
};
|
};
|
||||||
treatmentType: string | null;
|
prosthesisGroups: LabCaseProsthesisGroup[];
|
||||||
taskProgress: { completed: number; total: number };
|
taskProgress: { completed: number; total: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +126,14 @@ export interface ListLabCasesParams {
|
|||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
clinicOrganizationId?: string;
|
clinicOrganizationId?: string;
|
||||||
treatmentType?: string;
|
prosthesisTypeCode?: string;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CasesFilterOptions {
|
export interface CasesFilterOptions {
|
||||||
clinics: Array<{ id: string; name: string }>;
|
clinics: Array<{ id: string; name: string }>;
|
||||||
treatmentTypes: Array<{ code: string; labDependent: boolean }>;
|
prosthesisTypes: Array<{ code: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedLabCases {
|
export interface PaginatedLabCases {
|
||||||
@@ -160,6 +165,8 @@ export interface ListLabTasksParams {
|
|||||||
pinImportant?: boolean;
|
pinImportant?: boolean;
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
|
unassignedOnly?: boolean;
|
||||||
|
prosthesisTypeCode?: string;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
@@ -178,6 +185,9 @@ export interface LocateTaskPageParams {
|
|||||||
important?: boolean;
|
important?: boolean;
|
||||||
pinImportant?: boolean;
|
pinImportant?: boolean;
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
|
overdue?: boolean;
|
||||||
|
unassignedOnly?: boolean;
|
||||||
|
prosthesisTypeCode?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type TodaySummaryCharts = {
|
|||||||
appointmentsWeekMine?: TodayChartBucket[];
|
appointmentsWeekMine?: TodayChartBucket[];
|
||||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||||
casePartnersMonth?: TodayPartnerCasesBucket[];
|
casePartnersMonth?: TodayPartnerCasesBucket[];
|
||||||
|
casesDueWeek?: TodayChartBucket[];
|
||||||
efficiencyReport?: TodayChartBucket[];
|
efficiencyReport?: TodayChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +59,8 @@ export type TodayWidgetKey =
|
|||||||
| 'casesInProgress'
|
| 'casesInProgress'
|
||||||
| 'tasksInProgress'
|
| 'tasksInProgress'
|
||||||
| 'importantTasks'
|
| 'importantTasks'
|
||||||
|
| 'overdueCases'
|
||||||
|
| 'unassignedTasks'
|
||||||
| 'pendingConnections'
|
| 'pendingConnections'
|
||||||
| 'pendingStaffInvites'
|
| 'pendingStaffInvites'
|
||||||
| 'providersWithoutWorkingHours';
|
| 'providersWithoutWorkingHours';
|
||||||
@@ -79,7 +82,7 @@ export type TodaySubscriptionSnapshot = {
|
|||||||
export type TodaySummaryWidgets = Partial<
|
export type TodaySummaryWidgets = Partial<
|
||||||
Record<
|
Record<
|
||||||
TodayWidgetKey,
|
TodayWidgetKey,
|
||||||
| { count: number }
|
| { count: number; membershipIds?: string[] }
|
||||||
| { used: number; limit: number | null; unlimited: boolean }
|
| { used: number; limit: number | null; unlimited: boolean }
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user