Case completion and tasks completion gadgets added.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -235,8 +235,16 @@
|
||||
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
||||
"chartCaseCompletionPercent": "{percent}٪",
|
||||
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||
"chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان",
|
||||
"chartTreatmentPlanCompletionSubtitle": "همه نوبتها",
|
||||
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
||||
"chartCasePartnersSentLegend": "ارسالشده",
|
||||
"chartCasePartnersOpenLegend": "در حال انجام",
|
||||
"chartEfficiencyReportTitle": "گزارش کارایی",
|
||||
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
78
frontend/src/components/today/today-gadget-order.ts
Normal file
78
frontend/src/components/today/today-gadget-order.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user