From 0f1004a34a1ebbab8d65d830699e176cfb6e8bcb Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 02:08:54 +0330 Subject: [PATCH] Case completion and tasks completion gadgets added. --- backend/src/modules/today/today.service.ts | 270 ++++++++++++++---- frontend/messages/en.json | 8 + frontend/messages/fa.json | 8 + frontend/messages/nl.json | 8 + ...rd.tsx => TodayCompletionGaugeKpiCard.tsx} | 44 +-- .../src/components/today/TodayDashboard.tsx | 148 +++++++++- .../TodayPartnerCasesStackedBarChart.tsx | 113 ++++++++ .../today/today-dashboard-layout.ts | 5 + .../components/today/today-gadget-order.ts | 78 +++++ frontend/src/types/today.ts | 21 +- 10 files changed, 616 insertions(+), 87 deletions(-) rename frontend/src/components/today/{TodayCaseCompletionKpiCard.tsx => TodayCompletionGaugeKpiCard.tsx} (63%) create mode 100644 frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx create mode 100644 frontend/src/components/today/today-gadget-order.ts diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 10f5de6..c547d33 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -30,20 +30,29 @@ type StackedDayBucket = { received: number; }; -type CaseCompletionChart = { +type CompletionGaugeChart = { completed: number; total: number; percent: number; }; +type PartnerCasesBucket = { + code: string; + label: string; + completed: number; + pending: number; +}; + type TodayCharts = { treatmentMixWeek?: ChartBucket[]; tasksByProsthesis?: ChartBucket[]; appointmentsByProvider?: ChartBucket[]; - caseCompletion?: CaseCompletionChart; + caseCompletion?: CompletionGaugeChart; + treatmentPlanCompletion?: CompletionGaugeChart; appointmentsWeekAll?: ChartBucket[]; appointmentsWeekMine?: ChartBucket[]; labTaskActivityWeek?: StackedDayBucket[]; + casePartnersMonth?: PartnerCasesBucket[]; efficiencyReport?: ChartBucket[]; }; @@ -138,6 +147,27 @@ export class TodayService { ); } + if (this.canEditTreatment(membership.isOwner, permissionNames)) { + tasks.push( + this.loadCasePartnersMonth( + 'CLINIC', + organizationId, + userId, + !membership.isOwner, + to, + charts, + ), + ); + tasks.push( + this.loadTreatmentPlanCompletion( + organizationId, + userId, + !membership.isOwner, + charts, + ), + ); + } + if (this.canViewTreatment(membership.isOwner, permissionNames)) { tasks.push( this.loadTreatmentsToday(organizationId, from, to, widgets), @@ -198,6 +228,19 @@ export class TodayService { tasks.push(this.loadCaseCompletion(organizationId, charts)); } + if (this.canEditCases(membership.isOwner, permissionNames)) { + tasks.push( + this.loadCasePartnersMonth( + 'LAB', + organizationId, + userId, + false, + to, + charts, + ), + ); + } + if (this.canViewTasks(membership.isOwner, permissionNames)) { tasks.push(this.loadTasksInProgress(organizationId, widgets)); tasks.push(this.loadImportantTasks(organizationId, widgets)); @@ -515,10 +558,10 @@ export class TodayService { widgets.pendingConnections = { count }; } - private async getActiveEditAccessUserIds( + private async getActiveEditAccessMembers( organizationId: string, editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT', - ): Promise { + ): Promise> { const members = await this.prisma.membership.findMany({ where: { organizationId, @@ -533,10 +576,42 @@ export class TodayService { }, ], }, - select: { userId: true }, + select: { userId: true, isOwner: true }, }); - return members.map((member) => member.userId); + return members.map((member) => ({ + userId: member.userId, + isOwner: member.isOwner, + })); + } + + private async buildEfficiencyReportRows( + members: Array<{ userId: string; isOwner: boolean }>, + countsByUser: Map, + ): Promise { + if (members.length === 0) { + return undefined; + } + + const userIds = members.map((member) => member.userId); + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }); + const nameById = new Map(users.map((user) => [user.id, user.name])); + + const rows = members + .map((member) => ({ + code: member.userId, + label: nameById.get(member.userId) ?? member.userId, + count: countsByUser.get(member.userId) ?? 0, + isOwner: member.isOwner, + })) + .filter((row) => !row.isOwner || row.count > 0) + .map(({ code, label, count }) => ({ code, label, count })) + .sort((a, b) => b.count - a.count); + + return rows.length >= 2 ? rows : undefined; } private async loadClinicEfficiencyReport( @@ -544,13 +619,10 @@ export class TodayService { rangeEnd: Date, charts: TodayCharts, ) { - const eligibleUserIds = await this.getActiveEditAccessUserIds( + const members = await this.getActiveEditAccessMembers( organizationId, 'TAB_TREATMENT_EDIT', ); - if (eligibleUserIds.length < 2) { - return; - } const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); const grouped = await this.prisma.treatment.groupBy({ @@ -558,31 +630,22 @@ export class TodayService { where: { organizationId, treatmentAt: { gte: monthStart, lt: rangeEnd }, - providerUserId: { in: eligibleUserIds }, + providerUserId: { in: members.map((member) => member.userId) }, }, _count: { _all: true }, }); const countsByUser = new Map( - eligibleUserIds.map((userId) => [userId, 0]), + members.map((member) => [member.userId, 0]), ); for (const row of grouped) { countsByUser.set(row.providerUserId, aggregateCount(row._count)); } - const users = await this.prisma.user.findMany({ - where: { id: { in: eligibleUserIds } }, - select: { id: true, name: true }, - }); - const nameById = new Map(users.map((user) => [user.id, user.name])); - - charts.efficiencyReport = eligibleUserIds - .map((userId) => ({ - code: userId, - label: nameById.get(userId) ?? userId, - count: countsByUser.get(userId) ?? 0, - })) - .sort((a, b) => b.count - a.count); + const report = await this.buildEfficiencyReportRows(members, countsByUser); + if (report) { + charts.efficiencyReport = report; + } } private async loadLabEfficiencyReport( @@ -590,13 +653,10 @@ export class TodayService { rangeEnd: Date, charts: TodayCharts, ) { - const eligibleUserIds = await this.getActiveEditAccessUserIds( + const members = await this.getActiveEditAccessMembers( labOrganizationId, 'TAB_TASKS_EDIT', ); - if (eligibleUserIds.length < 2) { - return; - } const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({ @@ -604,7 +664,7 @@ export class TodayService { where: { toStatus: LabTaskStatus.COMPLETED, changedAt: { gte: monthStart, lt: rangeEnd }, - changedByUserId: { in: eligibleUserIds }, + changedByUserId: { in: members.map((member) => member.userId) }, task: { labCase: { sends: { some: { organizationId: labOrganizationId } }, @@ -615,26 +675,17 @@ export class TodayService { }); const countsByUser = new Map( - eligibleUserIds.map((userId) => [userId, 0]), + members.map((member) => [member.userId, 0]), ); for (const row of grouped) { if (!row.changedByUserId) continue; countsByUser.set(row.changedByUserId, aggregateCount(row._count)); } - const users = await this.prisma.user.findMany({ - where: { id: { in: eligibleUserIds } }, - select: { id: true, name: true }, - }); - const nameById = new Map(users.map((user) => [user.id, user.name])); - - charts.efficiencyReport = eligibleUserIds - .map((userId) => ({ - code: userId, - label: nameById.get(userId) ?? userId, - count: countsByUser.get(userId) ?? 0, - })) - .sort((a, b) => b.count - a.count); + const report = await this.buildEfficiencyReportRows(members, countsByUser); + if (report) { + charts.efficiencyReport = report; + } } private async buildSubscriptionWidget( @@ -845,13 +896,42 @@ export class TodayService { select: { status: true }, }); - const total = tasks.length; - const completed = tasks.filter( - (task) => task.status === LabTaskStatus.COMPLETED, - ).length; - const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + charts.caseCompletion = this.buildCompletionGauge( + tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length, + tasks.length, + ); + } - charts.caseCompletion = { completed, total, percent }; + private async loadTreatmentPlanCompletion( + organizationId: string, + userId: string, + scopeToUser: boolean, + charts: TodayCharts, + ) { + const appointmentWhere = { + organizationId, + ...(scopeToUser ? { providerUserId: userId } : {}), + }; + + const [total, completed] = await Promise.all([ + this.prisma.appointment.count({ where: appointmentWhere }), + this.prisma.appointment.count({ + where: { + ...appointmentWhere, + treatment: { details: { some: {} } }, + }, + }), + ]); + + charts.treatmentPlanCompletion = this.buildCompletionGauge(completed, total); + } + + private buildCompletionGauge(completed: number, total: number): CompletionGaugeChart { + return { + completed, + total, + percent: total > 0 ? Math.round((completed / total) * 100) : 0, + }; } private async loadAppointmentsWeekAll( @@ -986,6 +1066,86 @@ export class TodayService { })); } + private async loadCasePartnersMonth( + orgType: 'CLINIC' | 'LAB', + organizationId: string, + userId: string, + scopeToUser: boolean, + rangeEnd: Date, + charts: TodayCharts, + ) { + const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); + + const cases = await this.prisma.labCase.findMany({ + where: { + sentAt: { gte: rangeStart, lt: rangeEnd }, + ...(orgType === 'CLINIC' + ? { + destinationOrganizationId: { not: null }, + treatment: { + organizationId, + ...(scopeToUser ? { providerUserId: userId } : {}), + }, + } + : { + sends: { some: { organizationId } }, + }), + }, + select: { + destinationOrganizationId: true, + tasks: { select: { status: true } }, + treatment: { select: { organizationId: true } }, + }, + }); + + const countsByPartner = new Map(); + + for (const labCase of cases) { + const partnerId = + orgType === 'CLINIC' + ? labCase.destinationOrganizationId + : labCase.treatment.organizationId; + if (!partnerId) continue; + + const isCompleted = + labCase.tasks.length > 0 && + labCase.tasks.every((task) => task.status === LabTaskStatus.COMPLETED); + + const entry = countsByPartner.get(partnerId) ?? { completed: 0, total: 0 }; + entry.total += 1; + if (isCompleted) entry.completed += 1; + countsByPartner.set(partnerId, entry); + } + + if (countsByPartner.size === 0) { + charts.casePartnersMonth = []; + return; + } + + const sorted = [...countsByPartner.entries()] + .map(([code, counts]) => ({ + code, + completed: counts.completed, + pending: counts.total - counts.completed, + total: counts.total, + })) + .sort((a, b) => b.total - a.total) + .slice(0, 8); + + const partners = await this.prisma.organization.findMany({ + where: { id: { in: sorted.map((row) => row.code) } }, + select: { id: true, name: true }, + }); + const nameById = new Map(partners.map((org) => [org.id, org.name])); + + charts.casePartnersMonth = sorted.map((row) => ({ + code: row.code, + label: nameById.get(row.code) ?? row.code, + completed: row.completed, + pending: row.pending, + })); + } + private getRequesterOrganizationId(sharedDataTypes: unknown): string | null { if (!sharedDataTypes || typeof sharedDataTypes !== 'object') { return null; @@ -1086,6 +1246,16 @@ export class TodayService { ); } + private canEditTreatment(isOwner: boolean, names: string[]): boolean { + if (isOwner) return true; + return names.includes('TAB_TREATMENT_EDIT'); + } + + private canEditCases(isOwner: boolean, names: string[]): boolean { + if (isOwner) return true; + return names.includes('TAB_CASES_EDIT'); + } + private canManageOrganizations(isOwner: boolean, names: string[]): boolean { if (isOwner) return true; return names.includes('TAB_ORGANIZATIONS_EDIT'); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a9d0791..f930b0a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -235,8 +235,16 @@ "chartCaseCompletionSubtitle": "All active cases", "chartCaseCompletionPercent": "{percent}%", "chartCaseCompletionTasks": "Tasks completed", + "chartTreatmentPlanCompletionTitle": "Treatment Plan Completion", + "chartTreatmentPlanCompletionSubtitle": "All appointments", + "chartTreatmentPlanCompletionRatio": "With treatment plan", "chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis", "chartTasksByProsthesisSubtitle": "Current workload mix", + "chartCasePartnersClinicTitle": "Cases by Lab", + "chartCasePartnersLabTitle": "Cases by Clinic", + "chartCasePartnersSubtitle": "Last 30 days", + "chartCasePartnersSentLegend": "Sent", + "chartCasePartnersOpenLegend": "In progress", "chartEfficiencyReportTitle": "Efficiency Report", "chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days", "chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index b49e76b..f55025d 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -235,8 +235,16 @@ "chartCaseCompletionSubtitle": "همه پرونده‌های فعال", "chartCaseCompletionPercent": "{percent}٪", "chartCaseCompletionTasks": "وظایف تکمیل‌شده", + "chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان", + "chartTreatmentPlanCompletionSubtitle": "همه نوبت‌ها", + "chartTreatmentPlanCompletionRatio": "دارای طرح درمان", "chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز", "chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی", + "chartCasePartnersClinicTitle": "کیس‌ها بر اساس لابراتوار", + "chartCasePartnersLabTitle": "کیس‌ها بر اساس کلینیک", + "chartCasePartnersSubtitle": "۳۰ روز گذشته", + "chartCasePartnersSentLegend": "ارسال‌شده", + "chartCasePartnersOpenLegend": "در حال انجام", "chartEfficiencyReportTitle": "گزارش کارایی", "chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته", "chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index ed30892..b98bf33 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -235,8 +235,16 @@ "chartCaseCompletionSubtitle": "Alle actieve cases", "chartCaseCompletionPercent": "{percent}%", "chartCaseCompletionTasks": "Taken voltooid", + "chartTreatmentPlanCompletionTitle": "Behandelplanvoltooiing", + "chartTreatmentPlanCompletionSubtitle": "Alle afspraken", + "chartTreatmentPlanCompletionRatio": "Met behandelplan", "chartTasksByProsthesisTitle": "Lopende taken per prothese", "chartTasksByProsthesisSubtitle": "Huidige werklastmix", + "chartCasePartnersClinicTitle": "Cases per lab", + "chartCasePartnersLabTitle": "Cases per kliniek", + "chartCasePartnersSubtitle": "Afgelopen 30 dagen", + "chartCasePartnersSentLegend": "Verzonden", + "chartCasePartnersOpenLegend": "In uitvoering", "chartEfficiencyReportTitle": "Efficiëntierapport", "chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen", "chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen", diff --git a/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx b/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx similarity index 63% rename from frontend/src/components/today/TodayCaseCompletionKpiCard.tsx rename to frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx index d6f4bee..243f696 100644 --- a/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx +++ b/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx @@ -1,42 +1,44 @@ 'use client'; -import { useTranslations } from 'next-intl'; -import { Package } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme'; import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import type { TodayCompletionGauge } from '@/types/today'; -interface TodayCaseCompletionKpiCardProps { - completed: number; - total: number; - percent: number; +export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge { + title: string; + subtitle: string; + percentLabel: string; + ratioLabel: string; + href: string; + icon: LucideIcon; } -export function TodayCaseCompletionKpiCard({ +export function TodayCompletionGaugeKpiCard({ completed, total, percent, -}: TodayCaseCompletionKpiCardProps) { - const t = useTranslations('today'); - - const percentLabel = - total > 0 ? t('chartCaseCompletionPercent', { percent }) : '—'; - + title, + subtitle, + percentLabel, + ratioLabel, + href, + icon: Icon, +}: TodayCompletionGaugeKpiCardProps) { return (
-

{t('chartCaseCompletionTitle')}

-

- {t('chartCaseCompletionSubtitle')} -

+

{title}

+

{subtitle}

- +
@@ -49,8 +51,8 @@ export function TodayCaseCompletionKpiCard({ percent={total > 0 ? percent : 0} completed={completed} total={total} - percentLabel={percentLabel} - tasksLabel={t('chartCaseCompletionTasks')} + percentLabel={total > 0 ? percentLabel : '—'} + tasksLabel={ratioLabel} fillColor={TODAY_CHART_COMPLETED_COLOR} showRatio={total > 0} /> diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/today/TodayDashboard.tsx index cb822cb..52423b8 100644 --- a/frontend/src/components/today/TodayDashboard.tsx +++ b/frontend/src/components/today/TodayDashboard.tsx @@ -4,6 +4,8 @@ import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; import { + canEditCases, + canEditTreatment, canViewAppointmentsTab, canViewCases, canViewLabCasesOrTasks, @@ -22,7 +24,9 @@ import { import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart'; import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; -import { TodayCaseCompletionKpiCard } from '@/components/today/TodayCaseCompletionKpiCard'; +import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart'; +import { Package, Stethoscope, type LucideIcon } from 'lucide-react'; +import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard'; import { mapLabTaskActivityChartData, TodayLabTaskActivityChart, @@ -42,6 +46,7 @@ import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/wi import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; import type { + TodayCompletionGauge, TodaySubscriptionSnapshot, TodaySummaryActions, TodaySummaryCharts, @@ -78,6 +83,12 @@ export function TodayDashboard({ currentOrganization && canViewMyAppointmentsWeekChart(currentOrganization); + const showCasePartnersChart = Boolean( + currentOrganization && + ((orgType === 'CLINIC' && canEditTreatment(currentOrganization)) || + (orgType === 'LAB' && canEditCases(currentOrganization))), + ); + const showCharts = useMemo(() => { if (!orgType || !currentOrganization) return false; if (orgType === 'CLINIC') { @@ -104,12 +115,18 @@ export function TodayDashboard({ Boolean(currentOrganization && canViewCases(currentOrganization)) && (isInitialLoad || charts.caseCompletion !== undefined); + const showTreatmentPlanCompletionCard = + orgType === 'CLINIC' && + Boolean(currentOrganization && canEditTreatment(currentOrganization)) && + (isInitialLoad || charts.treatmentPlanCompletion !== undefined); + const cells = useMemo(() => { if (isInitialLoad) { return buildSkeletonCells({ kpiDefinitions, showSubscriptionCard, showCaseCompletionCard, + showTreatmentPlanCompletionCard, showUpcoming: Boolean(showUpcoming), showCharts, orgType, @@ -118,6 +135,7 @@ export function TodayDashboard({ currentOrganization && canViewMyAppointmentsWeekChart(currentOrganization), ), + showCasePartnersChart, charts, }); } @@ -133,6 +151,9 @@ export function TodayDashboard({ showSubscriptionCard: showSubscriptionCard && Boolean(subscription), showCaseCompletionCard: showCaseCompletionCard && charts.caseCompletion !== undefined, + showTreatmentPlanCompletionCard: + showTreatmentPlanCompletionCard && + charts.treatmentPlanCompletion !== undefined, showUpcoming: Boolean(showUpcoming), showCharts, orgType, @@ -144,6 +165,7 @@ export function TodayDashboard({ kpiDefinitions, showSubscriptionCard, showCaseCompletionCard, + showTreatmentPlanCompletionCard, showUpcoming, showCharts, orgType, @@ -176,11 +198,13 @@ function buildSkeletonCells(options: { kpiDefinitions: ReturnType; showSubscriptionCard: boolean; showCaseCompletionCard: boolean; + showTreatmentPlanCompletionCard: boolean; showUpcoming: boolean; showCharts: boolean; orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; showMyAppointmentsWeekChart: boolean; + showCasePartnersChart: boolean; charts: TodaySummaryCharts; }): TodayDashboardCell[] { const cells: TodayDashboardCell[] = []; @@ -191,6 +215,7 @@ function buildSkeletonCells(options: { options.orgType, options.isOwner, options.showMyAppointmentsWeekChart, + options.showCasePartnersChart, ); for (let index = 0; index < Math.min(chartCount, 4); index += 1) { cells.push({ @@ -237,6 +262,14 @@ function buildSkeletonCells(options: { }); } + if (options.showTreatmentPlanCompletionCard) { + cells.push({ + id: 'treatment-plan-completion-skeleton', + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: , + }); + } + for (const definition of options.kpiDefinitions) { cells.push({ id: `kpi-skeleton-${definition.key}`, @@ -258,6 +291,7 @@ function buildDashboardCells(options: { kpiDefinitions: ReturnType; showSubscriptionCard: boolean; showCaseCompletionCard: boolean; + showTreatmentPlanCompletionCard: boolean; showUpcoming: boolean; showCharts: boolean; orgType?: 'CLINIC' | 'LAB'; @@ -277,6 +311,12 @@ function buildDashboardCells(options: { options.currentOrganization && canViewMyAppointmentsWeekChart(options.currentOrganization), ), + showCasePartnersChart: + Boolean(options.currentOrganization) && + ((options.orgType === 'CLINIC' && + canEditTreatment(options.currentOrganization)) || + (options.orgType === 'LAB' && + canEditCases(options.currentOrganization))), dayLabelFormatter: options.dayLabelFormatter, }), ); @@ -301,17 +341,35 @@ function buildDashboardCells(options: { } if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) { - const caseCompletion = options.charts.caseCompletion; - cells.push({ + pushCompletionGaugeCell(cells, { id: 'case-completion', - layout: TODAY_DASHBOARD_LAYOUT.subscription, - content: ( - - ), + gauge: options.charts.caseCompletion, + title: options.t('chartCaseCompletionTitle'), + subtitle: options.t('chartCaseCompletionSubtitle'), + percentLabel: options.t('chartCaseCompletionPercent', { + percent: options.charts.caseCompletion.percent, + }), + ratioLabel: options.t('chartCaseCompletionTasks'), + href: '/cases', + icon: Package, + }); + } + + if ( + options.showTreatmentPlanCompletionCard && + options.charts.treatmentPlanCompletion !== undefined + ) { + pushCompletionGaugeCell(cells, { + id: 'treatment-plan-completion', + gauge: options.charts.treatmentPlanCompletion, + title: options.t('chartTreatmentPlanCompletionTitle'), + subtitle: options.t('chartTreatmentPlanCompletionSubtitle'), + percentLabel: options.t('chartCaseCompletionPercent', { + percent: options.charts.treatmentPlanCompletion.percent, + }), + ratioLabel: options.t('chartTreatmentPlanCompletionRatio'), + href: '/appointments', + icon: Stethoscope, }); } @@ -345,6 +403,7 @@ function buildChartCells(options: { orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; showMyAppointmentsWeekChart: boolean; + showCasePartnersChart: boolean; dayLabelFormatter: ReturnType; }): TodayDashboardCell[] { const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options; @@ -528,6 +587,38 @@ function buildChartCells(options: { }); } + const casePartnersData = charts.casePartnersMonth ?? []; + if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) { + cells.push({ + id: 'chart-case-partners-month', + layout: barChart, + content: ( + row.completed === 0 && row.pending === 0, + )} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + return cells; } @@ -536,6 +627,7 @@ function countVisibleCharts( orgType?: 'CLINIC' | 'LAB', isOwner = false, showMyAppointmentsWeekChart = false, + showCasePartnersChart = false, ): number { let count = 0; if (orgType === 'CLINIC') { @@ -544,10 +636,12 @@ function countVisibleCharts( showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0; count += charts.appointmentsByProvider !== undefined ? 1 : 0; count += charts.treatmentMixWeek !== undefined ? 1 : 0; + count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0; } if (orgType === 'LAB') { count += charts.labTaskActivityWeek !== undefined ? 1 : 0; count += charts.tasksByProsthesis !== undefined ? 1 : 0; + count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0; } if ( isOwner && @@ -558,3 +652,35 @@ function countVisibleCharts( } return count; } + +function pushCompletionGaugeCell( + cells: TodayDashboardCell[], + options: { + id: string; + gauge: TodayCompletionGauge; + title: string; + subtitle: string; + percentLabel: string; + ratioLabel: string; + href: string; + icon: LucideIcon; + }, +) { + cells.push({ + id: options.id, + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: ( + + ), + }); +} diff --git a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx b/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx new file mode 100644 index 0000000..230e010 --- /dev/null +++ b/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COMPLETED_COLOR, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_RECEIVED_COLOR, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; +import type { TodayPartnerCasesBucket } from '@/types/today'; + +interface TodayPartnerCasesStackedBarChartProps { + data: TodayPartnerCasesBucket[]; + completedLabel: string; + pendingLabel: string; +} + +export function TodayPartnerCasesStackedBarChart({ + data, + completedLabel, + pendingLabel, +}: TodayPartnerCasesStackedBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + shortLabel: truncateLabel(item.label), + })); + + return ( + +
+
+ + + + + + { + const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined; + return row?.label ?? ''; + }} + /> + + + + +
+ +
+ + + {completedLabel} + + + + {pendingLabel} + +
+
+
+ ); +} + +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/today-dashboard-layout.ts b/frontend/src/components/today/today-dashboard-layout.ts index b638ddd..69a480a 100644 --- a/frontend/src/components/today/today-dashboard-layout.ts +++ b/frontend/src/components/today/today-dashboard-layout.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { getTodayGadgetFeatureOrder } from '@/components/today/today-gadget-order'; /** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */ export type TodayDashboardWidth = 1 | 2; @@ -49,6 +50,10 @@ export function sortDashboardCells { const byLayout = compareDashboardLayout(a.layout, b.layout); if (byLayout !== 0) return byLayout; + + const byFeature = getTodayGadgetFeatureOrder(a.id) - getTodayGadgetFeatureOrder(b.id); + if (byFeature !== 0) return byFeature; + return a.id.localeCompare(b.id); }); } diff --git a/frontend/src/components/today/today-gadget-order.ts b/frontend/src/components/today/today-gadget-order.ts new file mode 100644 index 0000000..9d6d153 --- /dev/null +++ b/frontend/src/components/today/today-gadget-order.ts @@ -0,0 +1,78 @@ +import type { TodayWidgetKey } from '@/types/today'; + +/** + * Feature domains for Today dashboard gadgets, ordered like app permissions: + * owner-only → staff → organizations → patients → appointments → treatment → cases → tasks + */ +export type TodayGadgetFeature = + | 'owner' + | 'staff' + | 'organizations' + | 'patients' + | 'appointments' + | 'treatment' + | 'cases' + | 'tasks'; + +export const TODAY_GADGET_FEATURE_SORT_ORDER: Record = { + owner: 0, + staff: 10, + organizations: 20, + patients: 30, + appointments: 40, + treatment: 50, + cases: 60, + tasks: 70, +}; + +/** KPI widgets — keyed by TodayWidgetKey. */ +export const TODAY_KPI_GADGET_FEATURE: Record = { + appointmentsToday: 'appointments', + patientsToday: 'patients', + treatmentsToday: 'treatment', + labCasesPendingSend: 'treatment', + providersWithoutWorkingHours: 'staff', + casesReceivedToday: 'cases', + casesInProgress: 'cases', + tasksInProgress: 'tasks', + importantTasks: 'tasks', + pendingConnections: 'organizations', + pendingStaffInvites: 'staff', +}; + +/** Charts and composite gadgets — keyed by stable cell id. */ +export const TODAY_GADGET_ID_FEATURE: Record = { + subscription: 'owner', + 'case-completion': 'cases', + 'treatment-plan-completion': 'treatment', + 'upcoming-appointments': 'treatment', + 'chart-efficiency-report': 'owner', + 'chart-appointments-week-all': 'appointments', + 'chart-appointments-week-mine': 'treatment', + 'chart-appointments-by-provider': 'appointments', + 'chart-treatment-mix': 'treatment', + 'chart-lab-task-activity': 'cases', + 'chart-tasks-by-prosthesis': 'tasks', + 'chart-case-partners-month': 'treatment', +}; + +export function todayGadgetFeatureSortRank(feature: TodayGadgetFeature): number { + return TODAY_GADGET_FEATURE_SORT_ORDER[feature]; +} + +export function getTodayGadgetFeatureOrder(gadgetId: string): number { + const direct = TODAY_GADGET_ID_FEATURE[gadgetId]; + if (direct) { + return todayGadgetFeatureSortRank(direct); + } + + if (gadgetId.startsWith('kpi-')) { + const key = gadgetId.slice(4) as TodayWidgetKey; + const feature = TODAY_KPI_GADGET_FEATURE[key]; + if (feature) { + return todayGadgetFeatureSortRank(feature); + } + } + + return Number.MAX_SAFE_INTEGER; +} diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index c66a3b8..5600a8d 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -23,18 +23,29 @@ export type TodayStackedDayBucket = { received: number; }; +export type TodayPartnerCasesBucket = { + code: string; + label: string; + completed: number; + pending: number; +}; + +export type TodayCompletionGauge = { + completed: number; + total: number; + percent: number; +}; + export type TodaySummaryCharts = { treatmentMixWeek?: TodayChartBucket[]; tasksByProsthesis?: TodayChartBucket[]; appointmentsByProvider?: TodayChartBucket[]; - caseCompletion?: { - completed: number; - total: number; - percent: number; - }; + caseCompletion?: TodayCompletionGauge; + treatmentPlanCompletion?: TodayCompletionGauge; appointmentsWeekAll?: TodayChartBucket[]; appointmentsWeekMine?: TodayChartBucket[]; labTaskActivityWeek?: TodayStackedDayBucket[]; + casePartnersMonth?: TodayPartnerCasesBucket[]; efficiencyReport?: TodayChartBucket[]; };