From 22466490bf4a2e0038646e847940cb012abd39fa Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 11 Jul 2026 00:07:48 +0330 Subject: [PATCH] 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 --- backend/src/app.module.ts | 2 + .../today/dto/today-summary-query.dto.ts | 20 + backend/src/modules/today/today.controller.ts | 26 ++ backend/src/modules/today/today.module.ts | 9 + backend/src/modules/today/today.service.ts | 425 ++++++++++++++++++ frontend/messages/en.json | 18 +- frontend/messages/fa.json | 18 +- frontend/messages/nl.json | 18 +- frontend/scripts/build-messages.mjs | 18 +- .../app/[locale]/(dashboard)/today/page.tsx | 43 +- frontend/src/components/today/KpiCard.tsx | 47 ++ .../src/components/today/TodayKpiGrid.tsx | 51 +++ .../src/components/today/widget-registry.ts | 219 +++++++++ frontend/src/lib/api/today.ts | 14 + frontend/src/lib/hooks/useTodaySummary.ts | 49 ++ frontend/src/types/today.ts | 32 ++ 16 files changed, 966 insertions(+), 43 deletions(-) create mode 100644 backend/src/modules/today/dto/today-summary-query.dto.ts create mode 100644 backend/src/modules/today/today.controller.ts create mode 100644 backend/src/modules/today/today.module.ts create mode 100644 backend/src/modules/today/today.service.ts create mode 100644 frontend/src/components/today/KpiCard.tsx create mode 100644 frontend/src/components/today/TodayKpiGrid.tsx create mode 100644 frontend/src/components/today/widget-registry.ts create mode 100644 frontend/src/lib/api/today.ts create mode 100644 frontend/src/lib/hooks/useTodaySummary.ts create mode 100644 frontend/src/types/today.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 11390d4..0eb8045 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/modules/today/dto/today-summary-query.dto.ts b/backend/src/modules/today/dto/today-summary-query.dto.ts new file mode 100644 index 0000000..4a311e2 --- /dev/null +++ b/backend/src/modules/today/dto/today-summary-query.dto.ts @@ -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; +} diff --git a/backend/src/modules/today/today.controller.ts b/backend/src/modules/today/today.controller.ts new file mode 100644 index 0000000..e9e88fe --- /dev/null +++ b/backend/src/modules/today/today.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/today/today.module.ts b/backend/src/modules/today/today.module.ts new file mode 100644 index 0000000..341a7ea --- /dev/null +++ b/backend/src/modules/today/today.module.ts @@ -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 {} diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts new file mode 100644 index 0000000..8f928d2 --- /dev/null +++ b/backend/src/modules/today/today.service.ts @@ -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[] = []; + + 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), + ); + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 487e65a..0cb9d2f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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…", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 66eeacb..49e0bef 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "در حال انتقال...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 271252c..a9730e1 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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...", diff --git a/frontend/scripts/build-messages.mjs b/frontend/scripts/build-messages.mjs index ac27577..206442d 100644 --- a/frontend/scripts/build-messages.mjs +++ b/frontend/scripts/build-messages.mjs @@ -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…', diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 107a1c4..915fc71 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -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 ( -
-

- {t('welcomeBack')} -

+
+

{t('welcomeBack')}

{showNoSubscriptionNotice && ( -
+

{t('noSubscriptionNotice')}{' '} @@ -29,27 +30,15 @@ export default function TodayPage() {

)} -
- -

{t('cardTodaysAppointments')}

-

12

-

Monday 2/5/2026

-
- -

{t('cardActivePatients')}

-

675

-
- -

{t('cardNewLabCase')}

-

5

-

35 ↑

-
- -

{t('cardTodayInvoices')}

-

1200$

-

21,300 $

-
-
+ {error ? ( +
+

+ {formatApiErrorMessage(error, t('loadError'))} +

+
+ ) : null} + +
); } diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/today/KpiCard.tsx new file mode 100644 index 0000000..315e998 --- /dev/null +++ b/frontend/src/components/today/KpiCard.tsx @@ -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 = { + 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 ( + +
+

{title}

+ {Icon ? : null} +
+ {loading ? ( +
+ ) : ( +

{value}

+ )} + {subtitle ? ( +

{subtitle}

+ ) : null} + + ); +} diff --git a/frontend/src/components/today/TodayKpiGrid.tsx b/frontend/src/components/today/TodayKpiGrid.tsx new file mode 100644 index 0000000..4908efe --- /dev/null +++ b/frontend/src/components/today/TodayKpiGrid.tsx @@ -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 ( +

{t('noWidgets')}

+ ); + } + + return ( +
+ {definitions.map((definition) => { + const value = definition.formatValue(widgets) ?? '—'; + const subtitleKey = definition.formatSubtitle?.(widgets); + const subtitle = + subtitleKey === 'unlimited' + ? t('seatsUnlimited') + : definition.formatSubtitle?.(widgets); + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts new file mode 100644 index 0000000..1f9b267 --- /dev/null +++ b/frontend/src/components/today/widget-registry.ts @@ -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, + ); +} diff --git a/frontend/src/lib/api/today.ts b/frontend/src/lib/api/today.ts new file mode 100644 index 0000000..04ae833 --- /dev/null +++ b/frontend/src/lib/api/today.ts @@ -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 => { + const response = await apiClient.get('/today/summary', { params }); + return response.data; + }, +}; diff --git a/frontend/src/lib/hooks/useTodaySummary.ts b/frontend/src/lib/hooks/useTodaySummary.ts new file mode 100644 index 0000000..a711e61 --- /dev/null +++ b/frontend/src/lib/hooks/useTodaySummary.ts @@ -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; +} + +export function useTodaySummary(enabled = true): UseTodaySummaryResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(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 }; +} diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts new file mode 100644 index 0000000..451fb79 --- /dev/null +++ b/frontend/src/types/today.ts @@ -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; +}