diff --git a/backend/src/modules/today/today.controller.ts b/backend/src/modules/today/today.controller.ts index e9e88fe..8135719 100644 --- a/backend/src/modules/today/today.controller.ts +++ b/backend/src/modules/today/today.controller.ts @@ -18,9 +18,14 @@ export class TodayController { }) getSummary( @Query() query: TodaySummaryQueryDto, - @Req() req: { user: { id: string; organizationId?: string } }, + @Req() req: { user: { id: string; organizationId?: string; language?: string } }, ) { const organizationId = this.todayService.getOrganizationIdFromUser(req.user); - return this.todayService.getSummary(req.user.id, organizationId, query); + return this.todayService.getSummary( + req.user.id, + organizationId, + query, + req.user.language, + ); } } diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 8f928d2..68fdc31 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -3,7 +3,7 @@ import { ForbiddenException, Injectable, } from '@nestjs/common'; -import { LabTaskStatus, LinkStatus } from '@prisma/client'; +import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { isUnlimitedSeats, @@ -13,8 +13,20 @@ import { OrganizationTypeName, ownerPermissionsForOrgType, } from '../../common/organization-type'; +import { + CatalogLabelService, + normalizeCatalogLocale, + type CatalogLocale, +} from '../catalog/catalog-label.service'; import { TodaySummaryQueryDto } from './dto/today-summary-query.dto'; +type ChartBucket = { code: string; label: string; count: number }; + +type TodayCharts = { + treatmentMixWeek?: ChartBucket[]; + tasksByWorkflowStep?: ChartBucket[]; +}; + type TodayWidgets = { appointmentsToday?: { count: number }; patientsToday?: { count: number }; @@ -31,7 +43,10 @@ type TodayWidgets = { @Injectable() export class TodayService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -44,6 +59,7 @@ export class TodayService { userId: string, organizationId: string, query: TodaySummaryQueryDto, + localeInput?: string | null, ) { const membership = await this.getActiveMembership(userId, organizationId); const permissionNames = this.resolvePermissionNames(membership); @@ -51,8 +67,10 @@ export class TodayService { const orgType = membership.organization.type.name as OrganizationTypeName; const { from, to } = this.resolveDayRange(query); + const locale = normalizeCatalogLocale(localeInput); const widgets: TodayWidgets = {}; + const charts: TodayCharts = {}; const tasks: Promise[] = []; if (orgType === 'CLINIC') { @@ -77,6 +95,9 @@ export class TodayService { ); tasks.push(this.loadDraftTreatments(organizationId, widgets)); tasks.push(this.loadLabCasesPendingSend(organizationId, widgets)); + tasks.push( + this.loadTreatmentMixWeek(organizationId, to, locale, charts), + ); } } @@ -90,6 +111,7 @@ export class TodayService { if (this.canViewTasks(membership.isOwner, permissionNames)) { tasks.push(this.loadTasksInProgress(organizationId, widgets)); tasks.push(this.loadImportantTasks(organizationId, widgets)); + tasks.push(this.loadTasksByWorkflowStep(organizationId, charts)); } } @@ -113,10 +135,81 @@ export class TodayService { orgType, range: { from: from.toISOString(), to: to.toISOString() }, widgets, + charts, }, }; } + private async loadTreatmentMixWeek( + organizationId: string, + rangeEnd: Date, + locale: CatalogLocale, + charts: TodayCharts, + ) { + const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000); + const grouped = await this.prisma.treatmentDetail.groupBy({ + by: ['treatmentType'], + where: { + treatment: { + organizationId, + treatmentAt: { gte: weekStart, lt: rangeEnd }, + }, + }, + _count: { _all: true }, + }); + + const sorted = grouped + .map((row) => ({ + code: row.treatmentType, + count: aggregateCount(row._count), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + if (sorted.length === 0) { + charts.treatmentMixWeek = []; + return; + } + + const labels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.TREATMENT_TYPE, + sorted.map((row) => row.code), + locale, + ); + + charts.treatmentMixWeek = sorted.map((row) => ({ + code: row.code, + label: labels.get(row.code) ?? row.code, + count: row.count, + })); + } + + private async loadTasksByWorkflowStep( + labOrganizationId: string, + charts: TodayCharts, + ) { + const grouped = await this.prisma.labCaseTask.groupBy({ + by: ['workflowStepCode', 'stepLabel'], + where: { + status: LabTaskStatus.IN_PROGRESS, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + _count: { _all: true }, + }); + + charts.tasksByWorkflowStep = grouped + .map((row) => ({ + code: row.workflowStepCode, + label: row.stepLabel, + count: aggregateCount(row._count), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + } + private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } { if (query.from && query.to) { const from = new Date(query.from); @@ -423,3 +516,10 @@ export class TodayService { ); } } + +function aggregateCount( + count: true | { _all?: number } | undefined, +): number { + if (!count || count === true) return 0; + return count._all ?? 0; +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0cb9d2f..7eb2b30 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -207,7 +207,12 @@ "widgetImportantTasks": "Important Tasks", "widgetPendingConnections": "Pending Connections", "widgetSeats": "Seat Usage", - "widgetPendingStaffInvites": "Pending Staff Invites" + "widgetPendingStaffInvites": "Pending Staff Invites", + "chartTreatmentMixTitle": "Treatment Mix", + "chartTreatmentMixSubtitle": "Last 7 days", + "chartTasksByStepTitle": "Tasks by Workflow Step", + "chartTasksByStepSubtitle": "In progress now", + "chartEmpty": "No data for this period yet." }, "staff": { "redirecting": "Redirecting…", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 49e0bef..fc5b34e 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -207,7 +207,12 @@ "widgetImportantTasks": "وظایف مهم", "widgetPendingConnections": "درخواست‌های اتصال در انتظار", "widgetSeats": "استفاده از صندلی", - "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار" + "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار", + "chartTreatmentMixTitle": "ترکیب درمان‌ها", + "chartTreatmentMixSubtitle": "۷ روز گذشته", + "chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار", + "chartTasksByStepSubtitle": "در حال انجام", + "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد." }, "staff": { "redirecting": "در حال انتقال...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a9730e1..c160f1e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -207,7 +207,12 @@ "widgetImportantTasks": "Belangrijke taken", "widgetPendingConnections": "Openstaande koppelingsverzoeken", "widgetSeats": "Zitplaatsgebruik", - "widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen" + "widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen", + "chartTreatmentMixTitle": "Behandelingsmix", + "chartTreatmentMixSubtitle": "Afgelopen 7 dagen", + "chartTasksByStepTitle": "Taken per workflowstap", + "chartTasksByStepSubtitle": "Nu in uitvoering", + "chartEmpty": "Nog geen gegevens voor deze periode." }, "staff": { "redirecting": "Bezig met doorsturen...", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01aeb15..f102b07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "recharts": "^3.9.2", "zod": "^4.3.6" }, "devDependencies": { @@ -77,6 +78,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1585,6 +1587,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1598,6 +1626,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -2128,6 +2162,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -2170,8 +2267,9 @@ "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2186,6 +2284,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -2231,6 +2335,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2756,6 +2861,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3059,6 +3165,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3126,6 +3233,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3242,6 +3350,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", @@ -3307,9 +3424,130 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -3389,6 +3627,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", @@ -3679,6 +3923,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -3708,6 +3962,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3893,6 +4148,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4132,6 +4388,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4632,6 +4894,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4674,6 +4946,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "11.2.8", "resolved": "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-11.2.8.tgz", @@ -5865,17 +6146,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -6295,6 +6565,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6304,6 +6575,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6316,6 +6588,7 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6331,8 +6604,78 @@ "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -6378,6 +6721,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", @@ -6967,6 +7316,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7008,6 +7363,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7170,6 +7526,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7325,6 +7682,37 @@ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -7465,6 +7853,7 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 647a797..7a5c0d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "recharts": "^3.9.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/scripts/build-messages.mjs b/frontend/scripts/build-messages.mjs index 206442d..c45e4cb 100644 --- a/frontend/scripts/build-messages.mjs +++ b/frontend/scripts/build-messages.mjs @@ -200,6 +200,11 @@ const en = { widgetPendingConnections: 'Pending Connections', widgetSeats: 'Seat Usage', widgetPendingStaffInvites: 'Pending Staff Invites', + chartTreatmentMixTitle: 'Treatment Mix', + chartTreatmentMixSubtitle: 'Last 7 days', + chartTasksByStepTitle: 'Tasks by Workflow Step', + chartTasksByStepSubtitle: 'In progress now', + chartEmpty: 'No data for this period yet.', }, staff: { redirecting: 'Redirecting…', diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 915fc71..b1051cb 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { TodayKpiGrid } from '@/components/today/TodayKpiGrid'; +import { TodayChartsSection } from '@/components/today/TodayChartsSection'; import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; @@ -39,6 +40,8 @@ export default function TodayPage() { ) : null} + + ); } diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/today/ChartCard.tsx new file mode 100644 index 0000000..0f294d9 --- /dev/null +++ b/frontend/src/components/today/ChartCard.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { Card } from '@/components/ui/shared/Card'; + +interface ChartCardProps { + title: string; + subtitle?: string; + children: ReactNode; + emptyMessage?: string; + isEmpty?: boolean; + loading?: boolean; +} + +export function ChartCard({ + title, + subtitle, + children, + emptyMessage, + isEmpty = false, + loading = false, +}: ChartCardProps) { + return ( + +
+

{title}

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+ + {loading ? ( +
+ ) : isEmpty ? ( +
+

{emptyMessage}

+
+ ) : ( +
{children}
+ )} + + ); +} diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/today/TodayBarChart.tsx new file mode 100644 index 0000000..ca997ed --- /dev/null +++ b/frontend/src/components/today/TodayBarChart.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { TodayChartBucket } from '@/types/today'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COLORS, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_TOOLTIP_BG, + TODAY_CHART_TOOLTIP_BORDER, +} from '@/components/today/chart-theme'; + +interface TodayBarChartProps { + data: TodayChartBucket[]; +} + +export function TodayBarChart({ data }: TodayBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + shortLabel: truncateLabel(item.label), + })); + + return ( + + + + + + { + const row = payload?.[0]?.payload as TodayChartBucket | undefined; + return row?.label ?? ''; + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + ); +} + +function truncateLabel(label: string, max = 12): string { + if (label.length <= max) return label; + return `${label.slice(0, max - 1)}…`; +} diff --git a/frontend/src/components/today/TodayChartsSection.tsx b/frontend/src/components/today/TodayChartsSection.tsx new file mode 100644 index 0000000..07a8810 --- /dev/null +++ b/frontend/src/components/today/TodayChartsSection.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { + canViewTasks, + canViewTreatment, +} from '@/components/shared/permissions'; +import { ChartCard } from '@/components/today/ChartCard'; +import { TodayBarChart } from '@/components/today/TodayBarChart'; +import type { TodaySummaryCharts } from '@/types/today'; + +interface TodayChartsSectionProps { + charts: TodaySummaryCharts; + loading?: boolean; +} + +export function TodayChartsSection({ charts, loading = false }: TodayChartsSectionProps) { + const t = useTranslations('today'); + const { currentOrganization } = useAuth(); + const orgType = currentOrganization?.type; + + const showTreatmentMix = + orgType === 'CLINIC' && canViewTreatment(currentOrganization); + const showTasksByStep = + orgType === 'LAB' && canViewTasks(currentOrganization); + + if (!showTreatmentMix && !showTasksByStep) { + return null; + } + + const treatmentData = charts.treatmentMixWeek ?? []; + const tasksData = charts.tasksByWorkflowStep ?? []; + + return ( +
+ {showTreatmentMix ? ( + + + + ) : null} + + {showTasksByStep ? ( + + + + ) : null} +
+ ); +} diff --git a/frontend/src/components/today/chart-theme.ts b/frontend/src/components/today/chart-theme.ts new file mode 100644 index 0000000..664bebe --- /dev/null +++ b/frontend/src/components/today/chart-theme.ts @@ -0,0 +1,16 @@ +/** Bar fill colors aligned with the dark dashboard accent palette. */ +export const TODAY_CHART_COLORS = [ + '#00bcff', + '#e1bc72', + '#98d8b5', + '#cfb8f7', + '#f8c9a6', + '#f4b6b6', + '#abd8f3', + '#cbe9a1', +] as const; + +export const TODAY_CHART_AXIS_COLOR = '#8ea3bf'; +export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)'; +export const TODAY_CHART_TOOLTIP_BG = '#14253d'; +export const TODAY_CHART_TOOLTIP_BORDER = '#29456a'; diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index 451fb79..0d3048b 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -1,3 +1,14 @@ +export type TodayChartBucket = { + code: string; + label: string; + count: number; +}; + +export type TodaySummaryCharts = { + treatmentMixWeek?: TodayChartBucket[]; + tasksByWorkflowStep?: TodayChartBucket[]; +}; + export type TodayWidgetKey = | 'appointmentsToday' | 'patientsToday' @@ -24,6 +35,7 @@ export interface TodaySummaryData { orgType: 'CLINIC' | 'LAB'; range: { from: string; to: string }; widgets: TodaySummaryWidgets; + charts: TodaySummaryCharts; } export interface TodaySummaryResponse {