Add Today dashboard foundation with permission-aware KPI summary API.

Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 00:07:48 +03:30
parent 893cd5b128
commit 22466490bf
16 changed files with 966 additions and 43 deletions

View File

@@ -0,0 +1,47 @@
import { Card } from '@/components/ui/shared/Card';
import type { KpiCardColor } from '@/components/today/widget-registry';
import type { LucideIcon } from 'lucide-react';
const colorClasses: Record<KpiCardColor, string> = {
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
purple: '!bg-purpose-consultation-bg !text-purpose-consultation-fg !border-purpose-consultation-border',
default: '',
};
interface KpiCardProps {
title: string;
value: string;
subtitle?: string | null;
icon?: LucideIcon;
color?: KpiCardColor;
loading?: boolean;
}
export function KpiCard({
title,
value,
subtitle,
icon: Icon,
color = 'default',
loading = false,
}: KpiCardProps) {
return (
<Card className={colorClasses[color]}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium">{title}</p>
{Icon ? <Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden /> : null}
</div>
{loading ? (
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
) : (
<p className="text-2xl font-bold mt-2">{value}</p>
)}
{subtitle ? (
<p className="text-xs opacity-80 mt-1">{subtitle}</p>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/hooks/useAuth';
import { KpiCard } from '@/components/today/KpiCard';
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
import type { TodaySummaryWidgets } from '@/types/today';
interface TodayKpiGridProps {
widgets: TodaySummaryWidgets;
loading?: boolean;
}
export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const definitions = loading
? getEligibleTodayKpis(currentOrganization)
: getVisibleTodayKpis(currentOrganization, widgets);
if (!loading && definitions.length === 0) {
return (
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{definitions.map((definition) => {
const value = definition.formatValue(widgets) ?? '—';
const subtitleKey = definition.formatSubtitle?.(widgets);
const subtitle =
subtitleKey === 'unlimited'
? t('seatsUnlimited')
: definition.formatSubtitle?.(widgets);
return (
<KpiCard
key={definition.key}
title={t(definition.titleKey)}
value={value}
subtitle={subtitle}
icon={definition.icon}
color={definition.color}
loading={loading}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,219 @@
import type { LucideIcon } from 'lucide-react';
import {
AlertCircle,
CalendarDays,
ClipboardList,
FlaskConical,
Link2,
Stethoscope,
UserCog,
Users,
} from 'lucide-react';
import type { Organization } from '@/types/organization';
import {
canAccessAppointmentsSection,
canEditStaff,
canViewCases,
canViewStaff,
canViewTasks,
canViewTreatment,
hasPermission,
type OrgTypeName,
} from '@/components/shared/permissions';
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
export interface TodayKpiDefinition {
key: TodayWidgetKey;
titleKey: string;
icon: LucideIcon;
color: KpiCardColor;
orgTypes: OrgTypeName[];
isVisible: (org: Organization | null) => boolean;
formatValue: (widgets: TodaySummaryWidgets) => string | null;
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
}
function countWidget(
widgets: TodaySummaryWidgets,
key: TodayWidgetKey,
): number | null {
const value = widgets[key];
if (!value || !('count' in value)) return null;
return value.count;
}
function canManageOrganizations(org: Organization | null): boolean {
if (!org) return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_ORGANIZATIONS_EDIT');
}
function canViewPatients(org: Organization | null): boolean {
if (!org) return false;
return (
hasPermission(org, 'TAB_PATIENTS_READ') ||
hasPermission(org, 'TAB_PATIENTS_EDIT')
);
}
export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
{
key: 'appointmentsToday',
titleKey: 'widgetAppointmentsToday',
icon: CalendarDays,
color: 'blue',
orgTypes: ['CLINIC'],
isVisible: (org) => canAccessAppointmentsSection(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'appointmentsToday');
return count === null ? null : String(count);
},
},
{
key: 'patientsToday',
titleKey: 'widgetPatientsToday',
icon: Users,
color: 'green',
orgTypes: ['CLINIC'],
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'patientsToday');
return count === null ? null : String(count);
},
},
{
key: 'treatmentsToday',
titleKey: 'widgetTreatmentsToday',
icon: Stethoscope,
color: 'purple',
orgTypes: ['CLINIC'],
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'treatmentsToday');
return count === null ? null : String(count);
},
},
{
key: 'draftTreatments',
titleKey: 'widgetDraftTreatments',
icon: ClipboardList,
color: 'yellow',
orgTypes: ['CLINIC'],
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'draftTreatments');
return count === null ? null : String(count);
},
},
{
key: 'labCasesPendingSend',
titleKey: 'widgetLabCasesPendingSend',
icon: FlaskConical,
color: 'red',
orgTypes: ['CLINIC'],
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'labCasesPendingSend');
return count === null ? null : String(count);
},
},
{
key: 'casesReceivedToday',
titleKey: 'widgetCasesReceivedToday',
icon: FlaskConical,
color: 'blue',
orgTypes: ['LAB'],
isVisible: (org) => canViewCases(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'casesReceivedToday');
return count === null ? null : String(count);
},
},
{
key: 'tasksInProgress',
titleKey: 'widgetTasksInProgress',
icon: ClipboardList,
color: 'yellow',
orgTypes: ['LAB'],
isVisible: (org) => canViewTasks(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'tasksInProgress');
return count === null ? null : String(count);
},
},
{
key: 'importantTasks',
titleKey: 'widgetImportantTasks',
icon: AlertCircle,
color: 'red',
orgTypes: ['LAB'],
isVisible: (org) => canViewTasks(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'importantTasks');
return count === null ? null : String(count);
},
},
{
key: 'pendingConnections',
titleKey: 'widgetPendingConnections',
icon: Link2,
color: 'yellow',
orgTypes: ['CLINIC', 'LAB'],
isVisible: (org) => canManageOrganizations(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'pendingConnections');
return count === null ? null : String(count);
},
},
{
key: 'seats',
titleKey: 'widgetSeats',
icon: UserCog,
color: 'default',
orgTypes: ['CLINIC', 'LAB'],
isVisible: (org) => canViewStaff(org),
formatValue: (widgets) => {
const seats = widgets.seats;
if (!seats || !('used' in seats)) return null;
if (seats.unlimited) return String(seats.used);
return `${seats.used}/${seats.limit ?? 0}`;
},
formatSubtitle: (widgets) => {
const seats = widgets.seats;
if (!seats || !('used' in seats)) return null;
return seats.unlimited ? 'unlimited' : null;
},
},
{
key: 'pendingStaffInvites',
titleKey: 'widgetPendingStaffInvites',
icon: UserCog,
color: 'purple',
orgTypes: ['CLINIC', 'LAB'],
isVisible: (org) => canEditStaff(org) || Boolean(org?.isOwner),
formatValue: (widgets) => {
const count = countWidget(widgets, 'pendingStaffInvites');
return count === null ? null : String(count);
},
},
];
export function getEligibleTodayKpis(org: Organization | null): TodayKpiDefinition[] {
if (!org) return [];
return TODAY_KPI_DEFINITIONS.filter((definition) => {
if (!definition.orgTypes.includes(org.type)) return false;
return definition.isVisible(org);
});
}
export function getVisibleTodayKpis(
org: Organization | null,
widgets: TodaySummaryWidgets,
): TodayKpiDefinition[] {
return getEligibleTodayKpis(org).filter(
(definition) => definition.formatValue(widgets) !== null,
);
}