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

@@ -17,6 +17,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
import { TodayModule } from './modules/today/today.module';
@Module({
imports: [
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
LabCaseCommentsModule,
StaffModule,
OrganizationModule,
TodayModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
export class TodaySummaryQueryDto {
@ApiPropertyOptional({
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
example: '2026-07-10T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
from?: string;
@ApiPropertyOptional({
description: 'End of the local day range (ISO 8601, exclusive). Defaults to next UTC midnight.',
example: '2026-07-11T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
to?: string;
}

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
import { TodayService } from './today.service';
@ApiTags('today')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('today')
export class TodayController {
constructor(private readonly todayService: TodayService) {}
@Get('summary')
@ApiOperation({
summary:
'Permission-aware dashboard summary for the Today tab (TAB_TODAY_READ or owner)',
})
getSummary(
@Query() query: TodaySummaryQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
return this.todayService.getSummary(req.user.id, organizationId, query);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TodayController } from './today.controller';
import { TodayService } from './today.service';
@Module({
controllers: [TodayController],
providers: [TodayService],
})
export class TodayModule {}

View File

@@ -0,0 +1,425 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { LabTaskStatus, LinkStatus } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
isUnlimitedSeats,
normalizeTabPermissions,
} from '../../common/permissions';
import {
OrganizationTypeName,
ownerPermissionsForOrgType,
} from '../../common/organization-type';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
type TodayWidgets = {
appointmentsToday?: { count: number };
patientsToday?: { count: number };
treatmentsToday?: { count: number };
draftTreatments?: { count: number };
labCasesPendingSend?: { count: number };
casesReceivedToday?: { count: number };
tasksInProgress?: { count: number };
importantTasks?: { count: number };
pendingConnections?: { count: number };
seats?: { used: number; limit: number | null; unlimited: boolean };
pendingStaffInvites?: { count: number };
};
@Injectable()
export class TodayService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async getSummary(
userId: string,
organizationId: string,
query: TodaySummaryQueryDto,
) {
const membership = await this.getActiveMembership(userId, organizationId);
const permissionNames = this.resolvePermissionNames(membership);
this.assertCanViewToday(membership.isOwner, permissionNames);
const orgType = membership.organization.type.name as OrganizationTypeName;
const { from, to } = this.resolveDayRange(query);
const widgets: TodayWidgets = {};
const tasks: Promise<void>[] = [];
if (orgType === 'CLINIC') {
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
tasks.push(
this.loadAppointmentsToday(organizationId, from, to, widgets),
);
}
if (
this.canViewPatients(membership.isOwner, permissionNames) ||
this.canViewAppointments(membership.isOwner, permissionNames)
) {
tasks.push(
this.loadPatientsToday(organizationId, from, to, widgets),
);
}
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
tasks.push(
this.loadTreatmentsToday(organizationId, from, to, widgets),
);
tasks.push(this.loadDraftTreatments(organizationId, widgets));
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
}
}
if (orgType === 'LAB') {
if (this.canViewCases(membership.isOwner, permissionNames)) {
tasks.push(
this.loadCasesReceivedToday(organizationId, from, to, widgets),
);
}
if (this.canViewTasks(membership.isOwner, permissionNames)) {
tasks.push(this.loadTasksInProgress(organizationId, widgets));
tasks.push(this.loadImportantTasks(organizationId, widgets));
}
}
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
tasks.push(
this.loadPendingConnections(organizationId, widgets),
);
}
if (this.canViewStaff(membership.isOwner, permissionNames)) {
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
}
await Promise.all(tasks);
return {
success: true,
data: {
generatedAt: new Date().toISOString(),
orgType,
range: { from: from.toISOString(), to: to.toISOString() },
widgets,
},
};
}
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
if (query.from && query.to) {
const from = new Date(query.from);
const to = new Date(query.to);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid date range');
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
}
return { from, to };
}
const now = new Date();
const from = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0),
);
const to = new Date(from.getTime() + 86_400_000);
return { from, to };
}
private async loadAppointmentsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.appointment.count({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
});
widgets.appointmentsToday = { count };
}
private async loadPatientsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const rows = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
select: { patientId: true },
distinct: ['patientId'],
});
widgets.patientsToday = { count: rows.length };
}
private async loadTreatmentsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.treatment.count({
where: {
organizationId,
treatmentAt: { gte: from, lt: to },
},
});
widgets.treatmentsToday = { count };
}
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.treatment.count({
where: {
organizationId,
details: { none: {} },
},
});
widgets.draftTreatments = { count };
}
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCase.count({
where: {
sentAt: null,
destinationOrganizationId: { not: null },
treatment: { organizationId },
},
});
widgets.labCasesPendingSend = { count };
}
private async loadCasesReceivedToday(
labOrganizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.labCase.count({
where: {
sentAt: { gte: from, lt: to },
sends: { some: { organizationId: labOrganizationId } },
},
});
widgets.casesReceivedToday = { count };
}
private async loadTasksInProgress(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCaseTask.count({
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
widgets.tasksInProgress = { count };
}
private async loadImportantTasks(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCaseTask.count({
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
isImportant: true,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
widgets.importantTasks = { count };
}
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
const links = await this.prisma.organizationLink.findMany({
where: {
status: LinkStatus.PENDING,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
select: { sharedDataTypes: true },
});
const count = links.filter((link) => {
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
return requesterOrgId !== null && requesterOrgId !== organizationId;
}).length;
widgets.pendingConnections = { count };
}
private async loadSeats(
organizationId: string,
plan: { maxUsers: number } | null,
widgets: TodayWidgets,
) {
const used = await this.prisma.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
const maxUsers = plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers);
widgets.seats = {
used,
limit: unlimited ? null : maxUsers,
unlimited,
};
}
private async loadPendingStaffInvites(organizationId: string, widgets: TodayWidgets) {
const members = await this.prisma.membership.findMany({
where: { organizationId, isOwner: false, isActive: false },
include: {
invitations: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
const count = members.filter((member) => {
const invitation = member.invitations[0];
if (!invitation || invitation.acceptedAt || invitation.revokedAt) {
return false;
}
return invitation.expiresAt.getTime() > Date.now();
}).length;
widgets.pendingStaffInvites = { count };
}
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
return null;
}
const requester = (sharedDataTypes as { requesterOrganizationId?: unknown })
.requesterOrganizationId;
return typeof requester === 'string' ? requester : null;
}
private async getActiveMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: { include: { permission: true } },
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
return membership;
}
private resolvePermissionNames(membership: {
isOwner: boolean;
organization: {
planId: string | null;
type: { name: string };
};
permissions: { permission: { name: string } }[];
}): string[] {
if (membership.isOwner) {
const orgType = (membership.organization.type.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
}
return normalizeTabPermissions(
membership.permissions.map((p) => p.permission.name),
);
}
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
if (isOwner) return;
if (!permissionNames.includes('TAB_TODAY_READ')) {
throw new ForbiddenException('You do not have access to Today');
}
}
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
[
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
].includes(p),
);
}
private canViewPatients(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'].includes(p),
);
}
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
);
}
private canViewCases(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_CASES_READ', 'TAB_CASES_EDIT'].includes(p),
);
}
private canViewTasks(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
);
}
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.includes('TAB_ORGANIZATIONS_EDIT');
}
private canViewStaff(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_STAFF_READ', 'TAB_STAFF_EDIT'].includes(p),
);
}
}

View File

@@ -194,10 +194,20 @@
"noSubscriptionNotice": "This organization does not have an active subscription yet.",
"choosePlanLink": "Choose a plan",
"noSubscriptionCta": "to start the purchase process.",
"cardTodaysAppointments": "Today's Appointments",
"cardActivePatients": "Active Patients",
"cardNewLabCase": "New Lab Case",
"cardTodayInvoices": "Today invoices"
"noWidgets": "No dashboard metrics are available for your current permissions.",
"loadError": "Could not load dashboard metrics.",
"seatsUnlimited": "Unlimited plan",
"widgetAppointmentsToday": "Today's Appointments",
"widgetPatientsToday": "Patients Today",
"widgetTreatmentsToday": "Treatments Today",
"widgetDraftTreatments": "Draft Treatments",
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
"widgetCasesReceivedToday": "Cases Received Today",
"widgetTasksInProgress": "Tasks In Progress",
"widgetImportantTasks": "Important Tasks",
"widgetPendingConnections": "Pending Connections",
"widgetSeats": "Seat Usage",
"widgetPendingStaffInvites": "Pending Staff Invites"
},
"staff": {
"redirecting": "Redirecting…",

View File

@@ -194,10 +194,20 @@
"noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.",
"choosePlanLink": "انتخاب طرح",
"noSubscriptionCta": "برای شروع فرآیند خرید.",
"cardTodaysAppointments": "نوبت‌های امروز",
"cardActivePatients": "بیماران فعال",
"cardNewLabCase": "پرونده جدید لابراتوار",
"cardTodayInvoices": "صورتحساب‌های امروز"
"noWidgets": "هیچ معیاری برای دسترسی فعلی شما در دسترس نیست.",
"loadError": "بارگذاری معیارهای داشبورد ناموفق بود.",
"seatsUnlimited": "طرح نامحدود",
"widgetAppointmentsToday": "نوبت‌های امروز",
"widgetPatientsToday": "بیماران امروز",
"widgetTreatmentsToday": "درمان‌های امروز",
"widgetDraftTreatments": "درمان‌های پیش‌نویس",
"widgetLabCasesPendingSend": "پرونده‌های در انتظار ارسال",
"widgetCasesReceivedToday": "پرونده‌های دریافتی امروز",
"widgetTasksInProgress": "وظایف در حال انجام",
"widgetImportantTasks": "وظایف مهم",
"widgetPendingConnections": "درخواست‌های اتصال در انتظار",
"widgetSeats": "استفاده از صندلی",
"widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار"
},
"staff": {
"redirecting": "در حال انتقال...",

View File

@@ -194,10 +194,20 @@
"noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.",
"choosePlanLink": "Kies een abonnement",
"noSubscriptionCta": "om het aankoopproces te starten.",
"cardTodaysAppointments": "Afspraken van vandaag",
"cardActivePatients": "Actieve patiënten",
"cardNewLabCase": "Nieuwe laboratoriumcase",
"cardTodayInvoices": "Facturen van vandaag"
"noWidgets": "Geen dashboardstatistieken beschikbaar voor uw huidige rechten.",
"loadError": "Dashboardstatistieken konden niet worden geladen.",
"seatsUnlimited": "Onbeperkt abonnement",
"widgetAppointmentsToday": "Afspraken van vandaag",
"widgetPatientsToday": "Patiënten vandaag",
"widgetTreatmentsToday": "Behandelingen vandaag",
"widgetDraftTreatments": "Conceptbehandelingen",
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
"widgetTasksInProgress": "Taken in uitvoering",
"widgetImportantTasks": "Belangrijke taken",
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
"widgetSeats": "Zitplaatsgebruik",
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen"
},
"staff": {
"redirecting": "Bezig met doorsturen...",

View File

@@ -186,10 +186,20 @@ const en = {
noSubscriptionNotice: 'This organization does not have an active subscription yet.',
choosePlanLink: 'Choose a plan',
noSubscriptionCta: 'to start the purchase process.',
cardTodaysAppointments: "Today's Appointments",
cardActivePatients: 'Active Patients',
cardNewLabCase: 'New Lab Case',
cardTodayInvoices: 'Today invoices',
noWidgets: 'No dashboard metrics are available for your current permissions.',
loadError: 'Could not load dashboard metrics.',
seatsUnlimited: 'Unlimited plan',
widgetAppointmentsToday: "Today's Appointments",
widgetPatientsToday: 'Patients Today',
widgetTreatmentsToday: 'Treatments Today',
widgetDraftTreatments: 'Draft Treatments',
widgetLabCasesPendingSend: 'Lab Cases Pending Send',
widgetCasesReceivedToday: 'Cases Received Today',
widgetTasksInProgress: 'Tasks In Progress',
widgetImportantTasks: 'Important Tasks',
widgetPendingConnections: 'Pending Connections',
widgetSeats: 'Seat Usage',
widgetPendingStaffInvites: 'Pending Staff Invites',
},
staff: {
redirecting: 'Redirecting…',

View File

@@ -3,22 +3,23 @@
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/shared/Card';
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
export default function TodayPage() {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const { data, loading, error } = useTodaySummary(Boolean(currentOrganization?.id));
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return (
<div>
<h1 className="text-2xl font-semibold mb-6">
{t('welcomeBack')}
</h1>
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
{t('noSubscriptionNotice')}{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
@@ -29,27 +30,15 @@ export default function TodayPage() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card>
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
<p className="text-2xl font-semibold mt-2">12</p>
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
<p className="text-2xl font-semibold mt-2">5</p>
<p className="text-xs text-text-muted mt-1">35 </p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
<p className="text-2xl font-semibold mt-2">1200$</p>
<p className="text-xs text-text-muted mt-1">21,300 $</p>
</Card>
{error ? (
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4">
<p className="text-sm text-badge-danger-fg">
{formatApiErrorMessage(error, t('loadError'))}
</p>
</div>
) : null}
<TodayKpiGrid widgets={data?.widgets ?? {}} loading={loading} />
</div>
);
}

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,
);
}

View File

@@ -0,0 +1,14 @@
import { apiClient } from './client';
import type { TodaySummaryResponse } from '@/types/today';
export interface TodaySummaryParams {
from: string;
to: string;
}
export const todayApi = {
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
const response = await apiClient.get('/today/summary', { params });
return response.data;
},
};

View File

@@ -0,0 +1,49 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
import { todayApi } from '@/lib/api/today';
import type { TodaySummaryData } from '@/types/today';
import type { ApiError } from '@/types/api';
interface UseTodaySummaryResult {
data: TodaySummaryData | null;
loading: boolean;
error: ApiError | null;
reload: () => Promise<void>;
}
export function useTodaySummary(enabled = true): UseTodaySummaryResult {
const [data, setData] = useState<TodaySummaryData | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<ApiError | null>(null);
const reload = useCallback(async () => {
if (!enabled) {
setData(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const range = getLocalDayIsoRange(new Date());
const response = await todayApi.summary(range);
setData(response.data);
} catch (err) {
setData(null);
setError(err as ApiError);
} finally {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
void reload();
}, [reload]);
return { data, loading, error, reload };
}

View File

@@ -0,0 +1,32 @@
export type TodayWidgetKey =
| 'appointmentsToday'
| 'patientsToday'
| 'treatmentsToday'
| 'draftTreatments'
| 'labCasesPendingSend'
| 'casesReceivedToday'
| 'tasksInProgress'
| 'importantTasks'
| 'pendingConnections'
| 'seats'
| 'pendingStaffInvites';
export type TodaySummaryWidgets = Partial<
Record<
TodayWidgetKey,
| { count: number }
| { used: number; limit: number | null; unlimited: boolean }
>
>;
export interface TodaySummaryData {
generatedAt: string;
orgType: 'CLINIC' | 'LAB';
range: { from: string; to: string };
widgets: TodaySummaryWidgets;
}
export interface TodaySummaryResponse {
success: boolean;
data: TodaySummaryData;
}