feature/dashboard #57

Merged
rameen merged 9 commits from feature/dashboard into master 2026-07-12 09:40:50 +03:30
10 changed files with 616 additions and 87 deletions
Showing only changes of commit 0f1004a34a - Show all commits

View File

@@ -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<string[]> {
): Promise<Array<{ userId: string; isOwner: boolean }>> {
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<string, number>,
): Promise<ChartBucket[] | undefined> {
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<string, { completed: number; total: number }>();
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');

View File

@@ -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",

View File

@@ -235,8 +235,16 @@
"chartCaseCompletionSubtitle": "همه پرونده‌های فعال",
"chartCaseCompletionPercent": "{percent}٪",
"chartCaseCompletionTasks": "وظایف تکمیل‌شده",
"chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان",
"chartTreatmentPlanCompletionSubtitle": "همه نوبت‌ها",
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
"chartCasePartnersClinicTitle": "کیس‌ها بر اساس لابراتوار",
"chartCasePartnersLabTitle": "کیس‌ها بر اساس کلینیک",
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
"chartCasePartnersSentLegend": "ارسال‌شده",
"chartCasePartnersOpenLegend": "در حال انجام",
"chartEfficiencyReportTitle": "گزارش کارایی",
"chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته",
"chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته",

View File

@@ -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",

View File

@@ -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 (
<Link
href="/cases"
href={href}
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
>
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t('chartCaseCompletionTitle')}</p>
<p className="mt-0.5 truncate text-xs text-text-muted">
{t('chartCaseCompletionSubtitle')}
</p>
<p className="text-sm font-medium">{title}</p>
<p className="mt-0.5 truncate text-xs text-text-muted">{subtitle}</p>
</div>
<Package className="h-4 w-4 shrink-0 !text-current" aria-hidden />
<Icon className="h-4 w-4 shrink-0 !text-current" aria-hidden />
</div>
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
@@ -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}
/>

View File

@@ -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<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[] = [];
@@ -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: <KpiCardSkeleton tall />,
});
}
for (const definition of options.kpiDefinitions) {
cells.push({
id: `kpi-skeleton-${definition.key}`,
@@ -258,6 +291,7 @@ function buildDashboardCells(options: {
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
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: (
<TodayCaseCompletionKpiCard
completed={caseCompletion.completed}
total={caseCompletion.total}
percent={caseCompletion.percent}
/>
),
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<typeof useTodayDayLabelFormatter>;
}): 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: (
<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;
}
@@ -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: (
<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}
/>
),
});
}

View File

@@ -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 (
<TodayChartFrame>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="shortLabel"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined;
return row?.label ?? '';
}}
/>
<Bar
dataKey="completed"
name={completedLabel}
stackId="cases"
fill={TODAY_CHART_COMPLETED_COLOR}
radius={[0, 0, 0, 0]}
maxBarSize={48}
/>
<Bar
dataKey="pending"
name={pendingLabel}
stackId="cases"
fill={TODAY_CHART_RECEIVED_COLOR}
radius={[4, 4, 0, 0]}
maxBarSize={48}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
aria-hidden
/>
{completedLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
aria-hidden
/>
{pendingLabel}
</span>
</div>
</div>
</TodayChartFrame>
);
}
function truncateLabel(label: string, max = 12): string {
if (label.length <= max) return label;
return `${label.slice(0, max - 1)}`;
}

View File

@@ -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<T extends { layout: TodayDashboardLayout; id:
return [...cells].sort((a, b) => {
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);
});
}

View File

@@ -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<TodayGadgetFeature, number> = {
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<TodayWidgetKey, TodayGadgetFeature> = {
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<string, TodayGadgetFeature> = {
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;
}

View File

@@ -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[];
};