diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index b05bb43..10f5de6 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -38,13 +38,12 @@ type CaseCompletionChart = { type TodayCharts = { treatmentMixWeek?: ChartBucket[]; - tasksByWorkflowStep?: ChartBucket[]; + tasksByProsthesis?: ChartBucket[]; appointmentsByProvider?: ChartBucket[]; caseCompletion?: CaseCompletionChart; appointmentsWeekAll?: ChartBucket[]; appointmentsWeekMine?: ChartBucket[]; labTaskActivityWeek?: StackedDayBucket[]; - inProgressTasksByProsthesis?: ChartBucket[]; efficiencyReport?: ChartBucket[]; }; @@ -76,7 +75,6 @@ type TodayWidgets = { appointmentsToday?: { count: number }; patientsToday?: { count: number }; treatmentsToday?: { count: number }; - draftTreatments?: { count: number }; labCasesPendingSend?: { count: number }; casesReceivedToday?: { count: number }; casesInProgress?: { count: number }; @@ -144,11 +142,22 @@ export class TodayService { tasks.push( this.loadTreatmentsToday(organizationId, from, to, widgets), ); - tasks.push(this.loadDraftTreatments(organizationId, widgets)); tasks.push(this.loadLabCasesPendingSend(organizationId, widgets)); tasks.push( this.loadTreatmentMixWeek(organizationId, to, locale, charts), ); + } + + if (this.canViewMyAppointmentsWeekChart(membership.isOwner, permissionNames)) { + tasks.push( + this.loadUpcomingAppointmentsToday( + organizationId, + userId, + from, + to, + actions, + ), + ); tasks.push( this.loadAppointmentsWeekMine( organizationId, @@ -158,15 +167,6 @@ export class TodayService { charts, ), ); - tasks.push( - this.loadUpcomingAppointmentsToday( - organizationId, - userId, - from, - to, - actions, - ), - ); } if ( @@ -201,7 +201,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)); + tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts)); } if (canViewLabWork) { @@ -213,9 +213,6 @@ export class TodayService { charts, ), ); - tasks.push( - this.loadInProgressTasksByProsthesis(organizationId, locale, charts), - ); } } @@ -305,12 +302,13 @@ export class TodayService { })); } - private async loadTasksByWorkflowStep( + private async loadTasksByProsthesis( labOrganizationId: string, + locale: CatalogLocale, charts: TodayCharts, ) { const grouped = await this.prisma.labCaseTask.groupBy({ - by: ['workflowStepCode', 'stepLabel'], + by: ['prosthesisTypeCode'], where: { status: LabTaskStatus.IN_PROGRESS, labCase: { @@ -321,14 +319,29 @@ export class TodayService { _count: { _all: true }, }); - charts.tasksByWorkflowStep = grouped + const sorted = grouped .map((row) => ({ - code: row.workflowStepCode, - label: row.stepLabel, + code: row.prosthesisTypeCode, count: aggregateCount(row._count), })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); + .sort((a, b) => b.count - a.count); + + if (sorted.length === 0) { + charts.tasksByProsthesis = []; + return; + } + + const labels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.PROSTHESIS_TYPE, + sorted.map((row) => row.code), + locale, + ); + + charts.tasksByProsthesis = sorted.map((row) => ({ + code: row.code, + label: labels.get(row.code) ?? row.code, + count: row.count, + })); } private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } { @@ -432,16 +445,6 @@ export class TodayService { widgets.treatmentsToday = { count }; } - private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) { - const count = await this.prisma.treatment.count({ - where: { - organizationId, - details: { none: {} }, - }, - }); - widgets.draftTreatments = { count }; - } - private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) { const count = await this.prisma.labCase.count({ where: { @@ -983,48 +986,6 @@ export class TodayService { })); } - private async loadInProgressTasksByProsthesis( - labOrganizationId: string, - locale: CatalogLocale, - charts: TodayCharts, - ) { - const grouped = await this.prisma.labCaseTask.groupBy({ - by: ['prosthesisTypeCode'], - where: { - status: LabTaskStatus.IN_PROGRESS, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, - }, - _count: { _all: true }, - }); - - const sorted = grouped - .map((row) => ({ - code: row.prosthesisTypeCode, - count: aggregateCount(row._count), - })) - .sort((a, b) => b.count - a.count); - - if (sorted.length === 0) { - charts.inProgressTasksByProsthesis = []; - return; - } - - const labels = await this.catalogLabels.resolveLabels( - CatalogEntityKind.PROSTHESIS_TYPE, - sorted.map((row) => row.code), - locale, - ); - - charts.inProgressTasksByProsthesis = sorted.map((row) => ({ - code: row.code, - label: labels.get(row.code) ?? row.code, - count: row.count, - })); - } - private getRequesterOrganizationId(sharedDataTypes: unknown): string | null { if (!sharedDataTypes || typeof sharedDataTypes !== 'object') { return null; @@ -1106,6 +1067,11 @@ export class TodayService { ); } + private canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean { + if (isOwner) return false; + return names.includes('TAB_TREATMENT_EDIT'); + } + private canViewCases(isOwner: boolean, names: string[]): boolean { if (isOwner) return true; return names.some((p) => diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 18936e2..a9d0791 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -227,8 +227,6 @@ "chartLabTaskActivitySubtitle": "Last 7 days", "chartLabTaskCompletedLegend": "Completed", "chartLabTaskReceivedLegend": "Received", - "chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis", - "chartProsthesisMixSubtitle": "Current workload mix", "chartAppointmentsByProviderTitle": "Appointments by Provider", "chartAppointmentsByProviderSubtitle": "Today", "chartTreatmentMixTitle": "Treatment Mix", @@ -237,8 +235,8 @@ "chartCaseCompletionSubtitle": "All active cases", "chartCaseCompletionPercent": "{percent}%", "chartCaseCompletionTasks": "Tasks completed", - "chartTasksByStepTitle": "Tasks by Workflow Step", - "chartTasksByStepSubtitle": "In progress now", + "chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis", + "chartTasksByProsthesisSubtitle": "Current workload mix", "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 bed2fca..b49e76b 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -227,8 +227,6 @@ "chartLabTaskActivitySubtitle": "۷ روز گذشته", "chartLabTaskCompletedLegend": "تکمیل‌شده", "chartLabTaskReceivedLegend": "دریافت‌شده", - "chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز", - "chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی", "chartAppointmentsByProviderTitle": "نوبت‌ها بر اساس ارائه‌دهنده", "chartAppointmentsByProviderSubtitle": "امروز", "chartTreatmentMixTitle": "ترکیب درمان‌ها", @@ -237,8 +235,8 @@ "chartCaseCompletionSubtitle": "همه پرونده‌های فعال", "chartCaseCompletionPercent": "{percent}٪", "chartCaseCompletionTasks": "وظایف تکمیل‌شده", - "chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار", - "chartTasksByStepSubtitle": "در حال انجام", + "chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز", + "chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی", "chartEfficiencyReportTitle": "گزارش کارایی", "chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته", "chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 15f9988..ed30892 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -227,8 +227,6 @@ "chartLabTaskActivitySubtitle": "Afgelopen 7 dagen", "chartLabTaskCompletedLegend": "Voltooid", "chartLabTaskReceivedLegend": "Ontvangen", - "chartProsthesisMixTitle": "Lopende taken per prothese", - "chartProsthesisMixSubtitle": "Huidige werklastmix", "chartAppointmentsByProviderTitle": "Afspraken per behandelaar", "chartAppointmentsByProviderSubtitle": "Vandaag", "chartTreatmentMixTitle": "Behandelingsmix", @@ -237,8 +235,8 @@ "chartCaseCompletionSubtitle": "Alle actieve cases", "chartCaseCompletionPercent": "{percent}%", "chartCaseCompletionTasks": "Taken voltooid", - "chartTasksByStepTitle": "Taken per workflowstap", - "chartTasksByStepSubtitle": "Nu in uitvoering", + "chartTasksByProsthesisTitle": "Lopende taken per prothese", + "chartTasksByProsthesisSubtitle": "Huidige werklastmix", "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/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 16e1a60..c90b75c 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -146,6 +146,14 @@ export function canEditTreatment(org: Organization | null): boolean { return hasPermission(org, 'TAB_TREATMENT_EDIT'); } +/** Staff treatment editors only — personal schedule Today gadgets (not owners). */ +export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'CLINIC') return false; + if (org.isOwner) return false; + return hasPermission(org, 'TAB_TREATMENT_EDIT'); +} + /** View treatment workspace (read-only or edit) */ export function canViewTreatment(org: Organization | null): boolean { if (!org) return false; diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/today/ChartCard.tsx index d51592f..aa7d6e1 100644 --- a/frontend/src/components/today/ChartCard.tsx +++ b/frontend/src/components/today/ChartCard.tsx @@ -9,6 +9,24 @@ interface ChartCardProps { emptyMessage?: string; isEmpty?: boolean; loading?: boolean; + /** + * Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel). + * Chart column is independent and vertically centered. + */ + sidePanelLayout?: boolean; + chartPanel?: ReactNode; +} + +function ChartCardHeader({ + title, + subtitle, +}: Pick) { + return ( +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ ); } export function ChartCard({ @@ -18,23 +36,47 @@ export function ChartCard({ emptyMessage, isEmpty = false, loading = false, + sidePanelLayout = false, + chartPanel, }: ChartCardProps) { if (loading) { return ; } + if (sidePanelLayout) { + return ( + +
+ + {isEmpty ? ( +
+
+

{emptyMessage}

+
+
+ ) : ( +
{children}
+ )} +
+ + {!isEmpty && chartPanel ? ( +
+
+ {chartPanel} +
+
+ ) : null} +
+ ); + } + return ( -
-

{title}

- {subtitle ? ( -

{subtitle}

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

{emptyMessage}

+

{emptyMessage}

) : (
{children}
diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/today/TodayAreaChart.tsx index 063d2db..f3d7822 100644 --- a/frontend/src/components/today/TodayAreaChart.tsx +++ b/frontend/src/components/today/TodayAreaChart.tsx @@ -20,27 +20,39 @@ import { interface TodayAreaChartProps { data: TodayChartBucket[]; + color?: string; + gradientId?: string; + showXAxis?: boolean; } -export function TodayAreaChart({ data }: TodayAreaChartProps) { +export function TodayAreaChart({ + data, + color = TODAY_CHART_PRIMARY_COLOR, + gradientId = 'todayAreaFill', + showXAxis = true, +}: TodayAreaChartProps) { return ( - + - - - + + + - + {showXAxis ? ( + + ) : ( + + )} diff --git a/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx b/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx index bfddf82..d6f4bee 100644 --- a/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx +++ b/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx @@ -40,9 +40,12 @@ export function TodayCaseCompletionKpiCard({
-
+
0 ? percent : 0} completed={completed} total={total} diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/today/TodayDashboard.tsx index 3bf0af3..cb822cb 100644 --- a/frontend/src/components/today/TodayDashboard.tsx +++ b/frontend/src/components/today/TodayDashboard.tsx @@ -7,6 +7,7 @@ import { canViewAppointmentsTab, canViewCases, canViewLabCasesOrTasks, + canViewMyAppointmentsWeekChart, canViewTasks, canViewTreatment, } from '@/components/shared/permissions'; @@ -15,15 +16,17 @@ import { ChartCard } from '@/components/today/ChartCard'; import { TodayAreaChart } from '@/components/today/TodayAreaChart'; import { TodayBarChart } from '@/components/today/TodayBarChart'; import { - formatTodayChartDayLabel, mapWeekChartBuckets, useTodayDayLabelFormatter, } from '@/components/today/chart-day-labels'; import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; -import { TodayDonutChart } from '@/components/today/TodayDonutChart'; +import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart'; import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; import { TodayCaseCompletionKpiCard } from '@/components/today/TodayCaseCompletionKpiCard'; -import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart'; +import { + mapLabTaskActivityChartData, + TodayLabTaskActivityChart, +} from '@/components/today/TodayLabTaskActivityChart'; import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard'; import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments'; import { @@ -36,7 +39,7 @@ import { type TodayDashboardCell, } from '@/components/today/today-dashboard-layout'; import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; -import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; import type { TodaySubscriptionSnapshot, @@ -71,7 +74,9 @@ export function TodayDashboard({ const isOwner = Boolean(currentOrganization?.isOwner); const showUpcoming = - orgType === 'CLINIC' && currentOrganization && canViewTreatment(currentOrganization); + orgType === 'CLINIC' && + currentOrganization && + canViewMyAppointmentsWeekChart(currentOrganization); const showCharts = useMemo(() => { if (!orgType || !currentOrganization) return false; @@ -109,6 +114,10 @@ export function TodayDashboard({ showCharts, orgType, isOwner, + showMyAppointmentsWeekChart: Boolean( + currentOrganization && + canViewMyAppointmentsWeekChart(currentOrganization), + ), charts, }); } @@ -171,12 +180,18 @@ function buildSkeletonCells(options: { showCharts: boolean; orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; + showMyAppointmentsWeekChart: boolean; charts: TodaySummaryCharts; }): TodayDashboardCell[] { const cells: TodayDashboardCell[] = []; if (options.showCharts) { - const chartCount = countVisibleCharts(options.charts, options.orgType, options.isOwner); + const chartCount = countVisibleCharts( + options.charts, + options.orgType, + options.isOwner, + options.showMyAppointmentsWeekChart, + ); for (let index = 0; index < Math.min(chartCount, 4); index += 1) { cells.push({ id: `chart-skeleton-${index}`, @@ -250,8 +265,6 @@ function buildDashboardCells(options: { currentOrganization: ReturnType['currentOrganization']; }): TodayDashboardCell[] { const cells: TodayDashboardCell[] = []; - const formatDayLabel = (code: string) => - formatTodayChartDayLabel(code, options.dayLabelFormatter); if (options.showCharts) { cells.push( @@ -260,7 +273,10 @@ function buildDashboardCells(options: { charts: options.charts, orgType: options.orgType, isOwner: options.isOwner, - formatDayLabel, + showMyAppointmentsWeekChart: Boolean( + options.currentOrganization && + canViewMyAppointmentsWeekChart(options.currentOrganization), + ), dayLabelFormatter: options.dayLabelFormatter, }), ); @@ -328,13 +344,13 @@ function buildChartCells(options: { charts: TodaySummaryCharts; orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; - formatDayLabel: (code: string) => string; + showMyAppointmentsWeekChart: boolean; dayLabelFormatter: ReturnType; }): TodayDashboardCell[] { - const { t, charts, orgType, isOwner } = options; + const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options; const cells: TodayDashboardCell[] = []; - const tallChart = TODAY_DASHBOARD_LAYOUT.chart; - const mediumChart = TODAY_DASHBOARD_LAYOUT.chartMedium; + const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea; + const barChart = TODAY_DASHBOARD_LAYOUT.chartBar; const appointmentsWeekAllData = mapWeekChartBuckets( charts.appointmentsWeekAll ?? [], @@ -348,11 +364,12 @@ function buildChartCells(options: { charts.labTaskActivityWeek ?? [], options.dayLabelFormatter, ); + const labTaskActivityChartData = mapLabTaskActivityChartData(labTaskActivityData); if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) { cells.push({ id: 'chart-appointments-week-all', - layout: tallChart, + layout: areaChart, content: ( - - - ), - }); - } - - const prosthesisData = charts.inProgressTasksByProsthesis ?? []; - if (orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined) { - cells.push({ - id: 'chart-prosthesis-mix', - layout: tallChart, - content: ( - - - prosthesisData.find((row) => row.code === code)?.label ?? code - } - colorForCode={(code, index) => prosthesisTypeColor(code, index)} - swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)} - variant="pie" - sideLegend /> ), @@ -442,7 +435,7 @@ function buildChartCells(options: { ) { cells.push({ id: 'chart-efficiency-report', - layout: tallChart, + layout: areaChart, content: ( row.count === 0)} emptyMessage={t('chartEmpty')} + sidePanelLayout + chartPanel={ + + efficiencyReportData.find((row) => row.code === code)?.label ?? code + } + variant="pie" + /> + } > - efficiencyReportData.find((row) => row.code === code)?.label ?? code } - variant="pie" - sideLegend /> ), @@ -471,7 +472,7 @@ function buildChartCells(options: { if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) { cells.push({ id: 'chart-appointments-by-provider', - layout: mediumChart, + layout: barChart, content: ( - + prosthesisTypeColor(code, index)} + /> ), }); @@ -531,18 +535,19 @@ function countVisibleCharts( charts: TodaySummaryCharts, orgType?: 'CLINIC' | 'LAB', isOwner = false, + showMyAppointmentsWeekChart = false, ): number { let count = 0; if (orgType === 'CLINIC') { count += charts.appointmentsWeekAll !== undefined ? 1 : 0; - count += charts.appointmentsWeekMine !== undefined ? 1 : 0; + count += + showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0; count += charts.appointmentsByProvider !== undefined ? 1 : 0; count += charts.treatmentMixWeek !== undefined ? 1 : 0; } if (orgType === 'LAB') { count += charts.labTaskActivityWeek !== undefined ? 1 : 0; - count += charts.inProgressTasksByProsthesis !== undefined ? 1 : 0; - count += charts.tasksByWorkflowStep !== undefined ? 1 : 0; + count += charts.tasksByProsthesis !== undefined ? 1 : 0; } if ( isOwner && diff --git a/frontend/src/components/today/TodayDonutChart.tsx b/frontend/src/components/today/TodayDonutChart.tsx index 63d4efa..302cbf3 100644 --- a/frontend/src/components/today/TodayDonutChart.tsx +++ b/frontend/src/components/today/TodayDonutChart.tsx @@ -5,19 +5,100 @@ import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import { - TODAY_CHART_COLORS, + chartRankColor, TODAY_CHART_TOOLTIP_STYLE, } from '@/components/today/chart-theme'; -interface TodayDonutChartProps { +interface TodayDonutChartBaseProps { data: TodayChartBucket[]; labelForCode: (code: string) => string; colorForCode?: (code: string, index: number) => string; swatchStyleForCode?: (code: string, index: number) => CSSProperties; +} + +interface TodayDonutChartProps extends TodayDonutChartBaseProps { variant?: 'donut' | 'pie'; + /** Inline legend + chart row (legacy). Prefer TodayDonutChartLegend + sidePanelLayout. */ sideLegend?: boolean; } +function useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, +}: TodayDonutChartBaseProps) { + const chartData = data.map((item) => ({ + ...item, + displayLabel: labelForCode(item.code), + })); + + const resolveColor = (code: string, index: number) => + colorForCode?.(code, index) ?? chartRankColor(index); + + const resolveSwatchStyle = (code: string, index: number): CSSProperties => + swatchStyleForCode?.(code, index) ?? { + backgroundColor: resolveColor(code, index), + borderColor: 'rgba(0, 0, 0, 0.18)', + }; + + return { chartData, resolveColor, resolveSwatchStyle }; +} + +export function TodayDonutChartLegend({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, +}: TodayDonutChartBaseProps) { + const { chartData, resolveSwatchStyle } = useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + }); + + const rowClass = 'flex h-4 items-center text-xs leading-none'; + + return ( +
+
+ {chartData.map((entry, index) => ( + + + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.displayLabel} + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.count} + + ))} +
+
+ ); +} + export function TodayDonutChart({ data, labelForCode, @@ -26,28 +107,19 @@ export function TodayDonutChart({ variant = 'donut', sideLegend = false, }: TodayDonutChartProps) { - const chartData = data.map((item) => ({ - ...item, - displayLabel: labelForCode(item.code), - })); + const { chartData, resolveColor } = useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + }); - const resolveColor = (code: string, index: number) => - colorForCode?.(code, index) ?? - TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]; + const innerRadius = variant === 'pie' ? 0 : '62%'; + const outerRadius = variant === 'pie' ? '88%' : 92; - const resolveSwatchStyle = (code: string, index: number): CSSProperties => - swatchStyleForCode?.(code, index) ?? { - backgroundColor: resolveColor(code, index), - borderColor: 'rgba(0, 0, 0, 0.18)', - }; - - const innerRadius = variant === 'pie' ? 0 : 62; - const outerRadius = sideLegend ? 100 : 92; - - const chart = ( - - - + const pieChart = ( + + - - + ); - if (!sideLegend) { - return chart; + if (sideLegend) { + return ( +
+
+ +
+
+ {pieChart} +
+
+ ); } - const rowClass = 'flex h-4 items-center text-xs leading-none'; - const legendInset = 'px-12'; - - return ( -
-
-
- {chartData.map((entry, index) => ( - - - - ))} -
- -
- {chartData.map((entry) => ( - - {entry.displayLabel} - - ))} -
- -
- {chartData.map((entry) => ( - - {entry.count} - - ))} -
-
- -
- - - - {chartData.map((entry, index) => ( - - ))} - - { - const row = item?.payload as TodayChartBucket | undefined; - return [value, row ? labelForCode(row.code) : '']; - }} - /> - - -
-
- ); + return {pieChart}; } diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/today/TodayHorizontalBarChart.tsx index fa43034..8e70812 100644 --- a/frontend/src/components/today/TodayHorizontalBarChart.tsx +++ b/frontend/src/components/today/TodayHorizontalBarChart.tsx @@ -13,8 +13,8 @@ import { import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { + chartRankColor, TODAY_CHART_AXIS_COLOR, - TODAY_CHART_COLORS, TODAY_CHART_GRID_COLOR, TODAY_CHART_TOOLTIP_STYLE, } from '@/components/today/chart-theme'; @@ -65,7 +65,7 @@ export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) {chartData.map((entry, index) => ( ))} diff --git a/frontend/src/components/today/TodayLabTaskActivityChart.tsx b/frontend/src/components/today/TodayLabTaskActivityChart.tsx new file mode 100644 index 0000000..b76f9d7 --- /dev/null +++ b/frontend/src/components/today/TodayLabTaskActivityChart.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { + Area, + AreaChart, + 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 { TodayStackedDayBucket } from '@/types/today'; + +export type LabTaskActivityChartRow = { + label: string; + completed: number; + received: number; +}; + +interface TodayLabTaskActivityChartProps { + data: LabTaskActivityChartRow[]; + completedLabel: string; + receivedLabel: string; +} + +export function TodayLabTaskActivityChart({ + data, + completedLabel, + receivedLabel, +}: TodayLabTaskActivityChartProps) { + return ( + +
+
+ + + + + + + + + + + + + + + + String(label)} + /> + + + + +
+ +
+ + + {completedLabel} + + + + {receivedLabel} + +
+
+
+ ); +} + +export function mapLabTaskActivityChartData( + buckets: TodayStackedDayBucket[], +): LabTaskActivityChartRow[] { + return buckets.map((bucket) => ({ + label: bucket.label, + completed: bucket.completed, + received: bucket.received, + })); +} diff --git a/frontend/src/components/today/TodayRadialGaugeChart.tsx b/frontend/src/components/today/TodayRadialGaugeChart.tsx index 8c49228..c3b8dbf 100644 --- a/frontend/src/components/today/TodayRadialGaugeChart.tsx +++ b/frontend/src/components/today/TodayRadialGaugeChart.tsx @@ -18,6 +18,12 @@ interface TodayRadialGaugeChartProps { size?: 'sm' | 'md'; fillColor?: string; showRatio?: boolean; + /** Override ring hole size (e.g. "72%" leaves more room for center labels). */ + innerRadius?: string | number; + /** Override compact chart wrapper height class when size is "sm". */ + compactClassName?: string; + /** Ring thickness when size is "sm". */ + compactBarSize?: number; } export function TodayRadialGaugeChart({ @@ -29,20 +35,28 @@ export function TodayRadialGaugeChart({ size = 'md', fillColor = TODAY_CHART_PRIMARY_COLOR, showRatio = true, + innerRadius, + compactClassName, + compactBarSize, }: TodayRadialGaugeChartProps) { const isCompact = size === 'sm'; const clamped = Math.max(0, Math.min(100, percent)); const data = [{ name: 'progress', value: clamped, fill: fillColor }]; + const resolvedInnerRadius = innerRadius ?? (isCompact ? '62%' : '68%'); + const resolvedBarSize = isCompact ? (compactBarSize ?? 9) : 14; + const wrapperClass = isCompact + ? compactClassName ?? 'h-[108px]' + : 'h-full min-h-0 flex-1'; return ( -
+
-
+
diff --git a/frontend/src/components/today/TodayStackedBarChart.tsx b/frontend/src/components/today/TodayStackedBarChart.tsx deleted file mode 100644 index 0b0a19a..0000000 --- a/frontend/src/components/today/TodayStackedBarChart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { - Bar, - BarChart, - CartesianGrid, - Legend, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; -import type { TodayStackedDayBucket } from '@/types/today'; -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'; - -interface TodayStackedBarChartProps { - data: TodayStackedDayBucket[]; - completedLabel: string; - receivedLabel: string; - formatDayLabel: (code: string) => string; -} - -export function TodayStackedBarChart({ - data, - completedLabel, - receivedLabel, - formatDayLabel, -}: TodayStackedBarChartProps) { - const chartData = data.map((item) => ({ - ...item, - dayLabel: formatDayLabel(item.code), - })); - - return ( - - - - - - - { - const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined; - return row ? formatDayLabel(row.code) : ''; - }} - /> - - - - - - - ); -} diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/today/TodayUpcomingAppointments.tsx index d62fa49..0f92981 100644 --- a/frontend/src/components/today/TodayUpcomingAppointments.tsx +++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx @@ -9,7 +9,7 @@ import { formatTimeForInput } from '@/components/appointments/appointmentTime'; import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection'; import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; -import { canViewTreatment } from '@/components/shared/permissions'; +import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { ListRowSkeleton } from '@/components/today/TodaySkeleton'; @@ -41,7 +41,7 @@ export function TodayUpcomingAppointments({ if ( !currentOrganization || currentOrganization.type !== 'CLINIC' || - !canViewTreatment(currentOrganization) + !canViewMyAppointmentsWeekChart(currentOrganization) ) { return null; } diff --git a/frontend/src/components/today/chart-theme.ts b/frontend/src/components/today/chart-theme.ts index 68ef694..50bef8c 100644 --- a/frontend/src/components/today/chart-theme.ts +++ b/frontend/src/components/today/chart-theme.ts @@ -3,10 +3,44 @@ import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-c /** Chart series colors — same palette as treatment / prosthesis catalog types. */ export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS; +/** + * Rank-based charts (efficiency report, appointments by provider): same hex pool as + * CATALOG_PALETTE_COLORS, reordered so consecutive ranks are visually distinct. + */ +const CHART_RANK_COLOR_ORDER = [ + '#fed7aa', // peach + '#93c5fd', // blue + '#86efac', // green + '#c4b5fd', // purple + '#f9a8d4', // pink + '#bae6fd', // sky + '#fde68a', // yellow + '#99f6e4', // teal + '#fca5a5', // salmon + '#ddd6fe', // lavender + '#fdba74', // orange — separated from peach + '#a5b4fc', // indigo + '#cbd5e1', // slate + '#d9f99d', // lime + '#fecaca', // light coral + '#fbcfe8', // pale pink +] as const; + +const chartRankColorSet = new Set(CHART_RANK_COLOR_ORDER); + +export const TODAY_CHART_RANK_COLORS: readonly string[] = [ + ...CHART_RANK_COLOR_ORDER, + ...CATALOG_PALETTE_COLORS.filter((color) => !chartRankColorSet.has(color)), +]; + +export function chartRankColor(index: number): string { + return TODAY_CHART_RANK_COLORS[index % TODAY_CHART_RANK_COLORS.length]; +} + /** Primary accent for single-series charts (area, gauge). */ export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd'; -/** Stacked bar segments for lab task activity. */ +/** Lab task activity series (completed / received). */ export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac'; export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd'; diff --git a/frontend/src/components/today/today-dashboard-layout.ts b/frontend/src/components/today/today-dashboard-layout.ts index 3061bc2..b638ddd 100644 --- a/frontend/src/components/today/today-dashboard-layout.ts +++ b/frontend/src/components/today/today-dashboard-layout.ts @@ -14,9 +14,13 @@ export const TODAY_DASHBOARD_LAYOUT = { kpi: { width: 1, height: 1 }, subscription: { width: 1, height: 2 }, upcoming: { width: 2, height: 3 }, - /** Tall charts: area, stacked bar, pie with side legend */ + /** Week area charts (appointments, lab task activity). */ + chartArea: { width: 2, height: 2 }, + /** Vertical / horizontal bar charts. */ + chartBar: { width: 2, height: 3 }, + /** @deprecated Prefer chartArea (height 2) or chartBar (height 3). */ chart: { width: 2, height: 3 }, - /** Medium charts: horizontal bar, vertical bar, radial gauge */ + /** @deprecated Use chartArea or chartBar */ chartMedium: { width: 2, height: 2 }, } as const satisfies Record; diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts index 50b7a83..aa27100 100644 --- a/frontend/src/components/today/widget-registry.ts +++ b/frontend/src/components/today/widget-registry.ts @@ -99,19 +99,6 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ return count === null ? null : String(count); }, }, - { - key: 'draftTreatments', - titleKey: 'widgetDraftTreatments', - icon: ClipboardList, - color: 'yellow', - orgTypes: ['CLINIC'], - href: '/treatment', - isVisible: (org) => canViewTreatment(org), - formatValue: (widgets) => { - const count = countWidget(widgets, 'draftTreatments'); - return count === null ? null : String(count); - }, - }, { key: 'labCasesPendingSend', titleKey: 'widgetLabCasesPendingSend', diff --git a/frontend/src/components/ui/treatment/catalog-type-colors.ts b/frontend/src/components/ui/treatment/catalog-type-colors.ts index 61db449..5ae0452 100644 --- a/frontend/src/components/ui/treatment/catalog-type-colors.ts +++ b/frontend/src/components/ui/treatment/catalog-type-colors.ts @@ -1,6 +1,7 @@ /** * Shared pastel palette for treatment types, prosthesis types, and dashboard charts. - * Treatment types own the canonical hex values; prosthesis types reuse the same codes. + * Treatment and prosthesis each have dedicated hex maps — prosthesis colors are unique + * within the prosthesis catalog (no duplicate swatches on charts or badges). */ export const TREATMENT_TYPE_COLORS: Record = { @@ -19,28 +20,28 @@ export const TREATMENT_TYPE_COLORS: Record = { continue_treatment: '#99f6e4', }; -/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */ +/** Dedicated prosthesis palette — one distinct pastel per catalog code. */ export const PROSTHESIS_TYPE_COLORS: Record = { - pfm_crown: '#cbd5e1', - pfz_crown: '#86efac', - monolithic_zirconia: '#99f6e4', - glass_ceramic_crown: '#fde68a', - full_metal_crown: '#cbd5e1', + pfm_crown: '#e2e8f0', + pfz_crown: '#bbf7d0', + monolithic_zirconia: '#e0f2fe', + glass_ceramic_crown: '#fef08a', + full_metal_crown: '#d4d4d8', temporary_resin_crown: '#bae6fd', - pmma: '#93c5fd', - peek_crown: '#99f6e4', - veneer_zirconia: '#86efac', + pmma: '#7dd3fc', + peek_crown: '#5eead4', + veneer_zirconia: '#6ee7b7', veneer_ips_press: '#fed7aa', veneer_ips_cad: '#fdba74', soft_structure: '#ddd6fe', customized_abutment: '#a5b4fc', - prefabricated_abutment: '#93c5fd', - ti_base_abutment: '#bae6fd', - multi_unit_abutment: '#a5b4fc', - zirconia_abutment: '#86efac', - screw_retained: '#c4b5fd', - zirconia_overlay: '#99f6e4', - ips_overlay: '#fde68a', + prefabricated_abutment: '#c7d2fe', + ti_base_abutment: '#bfdbfe', + multi_unit_abutment: '#818cf8', + zirconia_abutment: '#34d399', + screw_retained: '#e9d5ff', + zirconia_overlay: '#2dd4bf', + ips_overlay: '#fef3c7', smile_design: '#f9a8d4', mockup: '#fbcfe8', }; @@ -54,7 +55,12 @@ export const CATALOG_FALLBACK_COLORS = [ '#fbcfe8', ] as const; -/** Ordered palette for charts and rotating unknown catalog codes. */ +/** Fallback rotation for unknown prosthesis codes — drawn from the prosthesis palette. */ +export const PROSTHESIS_FALLBACK_COLORS: readonly string[] = [ + ...new Set(Object.values(PROSTHESIS_TYPE_COLORS)), +]; + +/** Ordered palette for charts and rotating unknown treatment catalog codes. */ export const CATALOG_PALETTE_COLORS: readonly string[] = [ '#fed7aa', '#fdba74', @@ -72,12 +78,25 @@ export const CATALOG_PALETTE_COLORS: readonly string[] = [ '#ddd6fe', '#d9f99d', '#fbcfe8', + ...PROSTHESIS_FALLBACK_COLORS.filter( + (color) => + ![ + '#fed7aa', + '#fdba74', + '#bae6fd', + '#f9a8d4', + '#ddd6fe', + '#fbcfe8', + '#a5b4fc', + ].includes(color), + ), ]; export function resolveCatalogTypeColor( code: string, colorMap: Record, index = 0, + fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS, ): string { - return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length]; + return colorMap[code] ?? fallbackColors[index % fallbackColors.length]; } diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts index 7d6b24d..278c83e 100644 --- a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts @@ -1,12 +1,13 @@ import type { CSSProperties } from 'react'; import { + PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, resolveCatalogTypeColor, } from '@/components/ui/treatment/catalog-type-colors'; /** * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group - * headers / badges). Uses the same hex palette as treatment types. + * headers / badges). Uses a dedicated pastel map (unique per prosthesis code). * * Clinic-facing dispatch flows intentionally do NOT use these colors. */ @@ -15,7 +16,7 @@ import { const BADGE_INK = '#14253d'; export function prosthesisTypeColor(code: string, index = 0): string { - return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index); + return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS); } /** Filled swatch (small indicator dots). */ diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index b932019..c66a3b8 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -25,7 +25,7 @@ export type TodayStackedDayBucket = { export type TodaySummaryCharts = { treatmentMixWeek?: TodayChartBucket[]; - tasksByWorkflowStep?: TodayChartBucket[]; + tasksByProsthesis?: TodayChartBucket[]; appointmentsByProvider?: TodayChartBucket[]; caseCompletion?: { completed: number; @@ -35,7 +35,6 @@ export type TodaySummaryCharts = { appointmentsWeekAll?: TodayChartBucket[]; appointmentsWeekMine?: TodayChartBucket[]; labTaskActivityWeek?: TodayStackedDayBucket[]; - inProgressTasksByProsthesis?: TodayChartBucket[]; efficiencyReport?: TodayChartBucket[]; }; @@ -43,7 +42,6 @@ export type TodayWidgetKey = | 'appointmentsToday' | 'patientsToday' | 'treatmentsToday' - | 'draftTreatments' | 'labCasesPendingSend' | 'casesReceivedToday' | 'casesInProgress'