Files
dyolink/frontend/src/components/ui/today/TodayDashboard.tsx

703 lines
21 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/hooks/useAuth';
import {
canEditCases,
canEditTreatment,
canViewAppointmentsTab,
canViewCases,
canViewLabCasesOrTasks,
canViewMyAppointmentsWeekChart,
canViewTasks,
canViewTreatment,
} from '@/components/shared/permissions';
import { KpiCard } from '@/components/ui/today/KpiCard';
import { ChartCard } from '@/components/ui/today/ChartCard';
import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart';
import { TodayBarChart } from '@/components/ui/today/TodayBarChart';
import {
mapWeekChartBuckets,
useTodayDayLabelFormatter,
} from '@/components/today/chart-day-labels';
import { TodayDashboardGrid } from '@/components/ui/today/TodayDashboardGrid';
import { TodayDonutChart, TodayDonutChartLegend } from '@/components/ui/today/TodayDonutChart';
import { TodayHorizontalBarChart } from '@/components/ui/today/TodayHorizontalBarChart';
import { TodayPartnerCasesStackedBarChart } from '@/components/ui/today/TodayPartnerCasesStackedBarChart';
import { Package, Stethoscope, type LucideIcon } from 'lucide-react';
import { TodayCompletionGaugeKpiCard } from '@/components/ui/today/TodayCompletionGaugeKpiCard';
import {
mapLabTaskActivityChartData,
TodayLabTaskActivityChart,
} from '@/components/ui/today/TodayLabTaskActivityChart';
import { TodaySubscriptionKpiCard } from '@/components/ui/today/TodaySubscriptionKpiCard';
import { TodayUpcomingAppointments } from '@/components/ui/today/TodayUpcomingAppointments';
import {
ChartCardSkeleton,
KpiCardSkeleton,
ListRowSkeleton,
} from '@/components/ui/today/TodaySkeleton';
import {
TODAY_DASHBOARD_LAYOUT,
type TodayDashboardCell,
} from '@/components/today/today-dashboard-layout';
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type {
TodayCompletionGauge,
TodaySubscriptionSnapshot,
TodaySummaryActions,
TodaySummaryCharts,
TodaySummaryWidgets,
} from '@/types/today';
interface TodayDashboardProps {
widgets: TodaySummaryWidgets;
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
subscription?: TodaySubscriptionSnapshot;
loading?: boolean;
isInitialLoad?: boolean;
hasError?: boolean;
}
export function TodayDashboard({
widgets,
charts,
actions,
subscription,
loading = false,
isInitialLoad = false,
hasError = false,
}: TodayDashboardProps) {
const t = useTranslations('today');
const dayLabelFormatter = useTodayDayLabelFormatter();
const { currentOrganization } = useAuth();
const orgType = currentOrganization?.type;
const isOwner = Boolean(currentOrganization?.isOwner);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
useEffect(() => {
if (orgType !== 'LAB') return;
void prosthesisCatalogApi
.list()
.then((response) => setProsthesisCatalog(response.data))
.catch(() => {});
}, [orgType]);
const showUpcoming =
orgType === 'CLINIC' &&
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') {
return (
canViewAppointmentsTab(currentOrganization) ||
canViewTreatment(currentOrganization)
);
}
return (
canViewCases(currentOrganization) ||
canViewTasks(currentOrganization) ||
canViewLabCasesOrTasks(currentOrganization)
);
}, [currentOrganization, orgType]);
const kpiDefinitions = isInitialLoad
? getEligibleTodayKpis(currentOrganization)
: getVisibleTodayKpis(currentOrganization, widgets);
const showSubscriptionCard = isOwner && (isInitialLoad || Boolean(subscription));
const showCaseCompletionCard =
orgType === 'LAB' &&
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,
isOwner,
showMyAppointmentsWeekChart: Boolean(
currentOrganization &&
canViewMyAppointmentsWeekChart(currentOrganization),
),
showCasePartnersChart,
charts,
});
}
return buildDashboardCells({
t,
dayLabelFormatter,
widgets,
charts,
actions,
subscription,
kpiDefinitions,
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
showCaseCompletionCard:
showCaseCompletionCard && charts.caseCompletion !== undefined,
showTreatmentPlanCompletionCard:
showTreatmentPlanCompletionCard &&
charts.treatmentPlanCompletion !== undefined,
showUpcoming: Boolean(showUpcoming),
showCharts,
orgType,
isOwner,
currentOrganization,
prosthesisCatalog,
});
}, [
isInitialLoad,
kpiDefinitions,
showSubscriptionCard,
showCaseCompletionCard,
showTreatmentPlanCompletionCard,
showUpcoming,
showCharts,
orgType,
isOwner,
charts,
t,
dayLabelFormatter,
widgets,
actions,
subscription,
currentOrganization,
prosthesisCatalog,
]);
if (hasError && !loading && cells.length === 0) {
return null;
}
if (!loading && !hasError && cells.length === 0) {
return (
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
</div>
);
}
return <TodayDashboardGrid cells={cells} loading={loading} />;
}
function buildSkeletonCells(options: {
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
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[] = [];
if (options.showCharts) {
const chartCount = countVisibleCharts(
options.charts,
options.orgType,
options.isOwner,
options.showMyAppointmentsWeekChart,
options.showCasePartnersChart,
);
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
cells.push({
id: `chart-skeleton-${index}`,
layout: TODAY_DASHBOARD_LAYOUT.chart,
content: <ChartCardSkeleton />,
});
}
}
if (options.showUpcoming) {
cells.push({
id: 'upcoming-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
content: (
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-3">
<div className="mb-2 space-y-1.5">
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
</div>
<div className="space-y-2">
{[0, 1].map((key) => (
<ListRowSkeleton key={key} compact />
))}
</div>
</div>
),
});
}
if (options.showSubscriptionCard) {
cells.push({
id: 'subscription-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
if (options.showCaseCompletionCard) {
cells.push({
id: 'case-completion-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
if (options.showTreatmentPlanCompletionCard) {
cells.push({
id: 'treatment-plan-completion-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
for (const definition of options.kpiDefinitions) {
cells.push({
id: `kpi-skeleton-${definition.key}`,
layout: TODAY_DASHBOARD_LAYOUT.kpi,
content: <KpiCardSkeleton />,
});
}
return cells;
}
function buildDashboardCells(options: {
t: ReturnType<typeof useTranslations<'today'>>;
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
widgets: TodaySummaryWidgets;
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
subscription?: TodaySubscriptionSnapshot;
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
showSubscriptionCard: boolean;
showCaseCompletionCard: boolean;
showTreatmentPlanCompletionCard: boolean;
showUpcoming: boolean;
showCharts: boolean;
orgType?: 'CLINIC' | 'LAB';
isOwner: boolean;
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
prosthesisCatalog: ProsthesisCatalogEntry[];
}): TodayDashboardCell[] {
const cells: TodayDashboardCell[] = [];
if (options.showCharts) {
cells.push(
...buildChartCells({
t: options.t,
charts: options.charts,
orgType: options.orgType,
isOwner: options.isOwner,
showMyAppointmentsWeekChart: Boolean(
options.currentOrganization &&
canViewMyAppointmentsWeekChart(options.currentOrganization),
),
showCasePartnersChart:
Boolean(options.currentOrganization) &&
((options.orgType === 'CLINIC' &&
canEditTreatment(options.currentOrganization)) ||
(options.orgType === 'LAB' &&
canEditCases(options.currentOrganization))),
dayLabelFormatter: options.dayLabelFormatter,
prosthesisCatalog: options.prosthesisCatalog,
}),
);
}
if (options.showUpcoming) {
cells.push({
id: 'upcoming-appointments',
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
content: (
<TodayUpcomingAppointments actions={options.actions} loading={false} isInitialLoad={false} />
),
});
}
if (options.showSubscriptionCard && options.subscription) {
cells.push({
id: 'subscription',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <TodaySubscriptionKpiCard subscription={options.subscription} />,
});
}
if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) {
pushCompletionGaugeCell(cells, {
id: 'case-completion',
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,
});
}
for (const definition of options.kpiDefinitions) {
const value = definition.formatValue(options.widgets) ?? '—';
const subtitle = definition.formatSubtitle?.(options.widgets);
cells.push({
id: `kpi-${definition.key}`,
layout: TODAY_DASHBOARD_LAYOUT.kpi,
content: (
<KpiCard
title={options.t(definition.titleKey)}
value={value}
subtitle={subtitle}
icon={definition.icon}
color={definition.color}
href={definition.href}
className="h-full"
/>
),
});
}
return cells;
}
function buildChartCells(options: {
t: ReturnType<typeof useTranslations<'today'>>;
charts: TodaySummaryCharts;
orgType?: 'CLINIC' | 'LAB';
isOwner: boolean;
showMyAppointmentsWeekChart: boolean;
showCasePartnersChart: boolean;
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
prosthesisCatalog: ProsthesisCatalogEntry[];
}): TodayDashboardCell[] {
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
const cells: TodayDashboardCell[] = [];
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
const appointmentsWeekAllData = mapWeekChartBuckets(
charts.appointmentsWeekAll ?? [],
options.dayLabelFormatter,
);
const appointmentsWeekMineData = mapWeekChartBuckets(
charts.appointmentsWeekMine ?? [],
options.dayLabelFormatter,
);
const labTaskActivityData = mapWeekChartBuckets(
charts.labTaskActivityWeek ?? [],
options.dayLabelFormatter,
);
const labTaskActivityChartData = mapLabTaskActivityChartData(labTaskActivityData);
if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) {
cells.push({
id: 'chart-appointments-week-all',
layout: areaChart,
content: (
<ChartCard
title={t('chartAppointmentsWeekAllTitle')}
subtitle={t('chartAppointmentsWeekAllSubtitle')}
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekAllData} />
</ChartCard>
),
});
}
if (
orgType === 'CLINIC' &&
showMyAppointmentsWeekChart &&
charts.appointmentsWeekMine !== undefined
) {
cells.push({
id: 'chart-appointments-week-mine',
layout: areaChart,
content: (
<ChartCard
title={t('chartAppointmentsWeekMineTitle')}
subtitle={t('chartAppointmentsWeekMineSubtitle')}
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekMineData} />
</ChartCard>
),
});
}
if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) {
cells.push({
id: 'chart-lab-task-activity',
layout: areaChart,
content: (
<ChartCard
title={t('chartLabTaskActivityTitle')}
subtitle={t('chartLabTaskActivitySubtitle')}
isEmpty={labTaskActivityData.every(
(row) => row.completed === 0 && row.received === 0,
)}
emptyMessage={t('chartEmpty')}
>
<TodayLabTaskActivityChart
data={labTaskActivityChartData}
completedLabel={t('chartLabTaskCompletedLegend')}
receivedLabel={t('chartLabTaskReceivedLegend')}
/>
</ChartCard>
),
});
}
const efficiencyReportData = charts.efficiencyReport ?? [];
if (
isOwner &&
charts.efficiencyReport !== undefined &&
efficiencyReportData.length >= 2
) {
cells.push({
id: 'chart-efficiency-report',
layout: areaChart,
content: (
<ChartCard
title={t('chartEfficiencyReportTitle')}
subtitle={
orgType === 'CLINIC'
? t('chartEfficiencyReportSubtitleClinic')
: t('chartEfficiencyReportSubtitleLab')
}
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
sidePanelLayout
chartPanel={
<TodayDonutChart
data={efficiencyReportData}
labelForCode={(code) =>
efficiencyReportData.find((row) => row.code === code)?.label ?? code
}
variant="pie"
/>
}
>
<TodayDonutChartLegend
data={efficiencyReportData}
labelForCode={(code) =>
efficiencyReportData.find((row) => row.code === code)?.label ?? code
}
/>
</ChartCard>
),
});
}
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) {
cells.push({
id: 'chart-appointments-by-provider',
layout: barChart,
content: (
<ChartCard
title={t('chartAppointmentsByProviderTitle')}
subtitle={t('chartAppointmentsByProviderSubtitle')}
isEmpty={appointmentsByProviderData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayHorizontalBarChart data={appointmentsByProviderData} />
</ChartCard>
),
});
}
const treatmentData = charts.treatmentMixWeek ?? [];
if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) {
cells.push({
id: 'chart-treatment-mix',
layout: barChart,
content: (
<ChartCard
title={t('chartTreatmentMixTitle')}
subtitle={t('chartTreatmentMixSubtitle')}
isEmpty={treatmentData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart
data={treatmentData}
colorForCode={(code, index) => treatmentTypeColor(code, index)}
/>
</ChartCard>
),
});
}
const tasksByProsthesisData = charts.tasksByProsthesis ?? [];
if (orgType === 'LAB' && charts.tasksByProsthesis !== undefined) {
cells.push({
id: 'chart-tasks-by-prosthesis',
layout: barChart,
content: (
<ChartCard
title={t('chartTasksByProsthesisTitle')}
subtitle={t('chartTasksByProsthesisSubtitle')}
isEmpty={tasksByProsthesisData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart
data={tasksByProsthesisData}
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
/>
</ChartCard>
),
});
}
const casePartnersData = charts.casePartnersMonth ?? [];
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
cells.push({
id: 'chart-case-partners-month',
layout: barChart,
content: (
<ChartCard
title={
orgType === 'CLINIC'
? t('chartCasePartnersClinicTitle')
: t('chartCasePartnersLabTitle')
}
subtitle={t('chartCasePartnersSubtitle')}
isEmpty={casePartnersData.every(
(row) => row.completed === 0 && row.pending === 0,
)}
emptyMessage={t('chartEmpty')}
>
<TodayPartnerCasesStackedBarChart
data={casePartnersData}
completedLabel={t('chartLabTaskCompletedLegend')}
pendingLabel={
orgType === 'CLINIC'
? t('chartCasePartnersSentLegend')
: t('chartCasePartnersOpenLegend')
}
/>
</ChartCard>
),
});
}
return cells;
}
function countVisibleCharts(
charts: TodaySummaryCharts,
orgType?: 'CLINIC' | 'LAB',
isOwner = false,
showMyAppointmentsWeekChart = false,
showCasePartnersChart = false,
): number {
let count = 0;
if (orgType === 'CLINIC') {
count += charts.appointmentsWeekAll !== undefined ? 1 : 0;
count +=
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 &&
charts.efficiencyReport !== undefined &&
(charts.efficiencyReport?.length ?? 0) >= 2
) {
count += 1;
}
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: (
<TodayCompletionGaugeKpiCard
completed={options.gauge.completed}
total={options.gauge.total}
percent={options.gauge.percent}
title={options.title}
subtitle={options.subtitle}
percentLabel={options.percentLabel}
ratioLabel={options.ratioLabel}
href={options.href}
icon={options.icon}
/>
),
});
}