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:
219
frontend/src/components/today/widget-registry.ts
Normal file
219
frontend/src/components/today/widget-registry.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user