From 83f6003a2ba3e0cb5212fafd293070b55555369a Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 11 Jul 2026 22:32:37 +0330 Subject: [PATCH] Positioning, sizing and sorting of the gadgets improved. --- backend/package.json | 1 + backend/prisma/wipe-app-data.ts | 112 ++++ .../appointments/appointments.controller.ts | 2 +- .../appointments/appointments.service.ts | 39 +- backend/src/modules/today/today.service.ts | 236 +++++++- backend/tsconfig.json | 1 - frontend/messages/en.json | 14 +- frontend/messages/fa.json | 14 +- frontend/messages/nl.json | 14 +- .../(dashboard)/appointments/page.tsx | 6 + .../app/[locale]/(dashboard)/today/page.tsx | 78 +-- frontend/src/components/shared/permissions.ts | 26 +- frontend/src/components/today/ChartCard.tsx | 8 +- frontend/src/components/today/KpiCard.tsx | 11 +- .../src/components/today/TodayAreaChart.tsx | 81 +-- .../src/components/today/TodayBarChart.tsx | 15 +- .../today/TodayCaseCompletionKpiCard.tsx | 59 ++ .../src/components/today/TodayChartFrame.tsx | 8 + .../components/today/TodayChartsSection.tsx | 243 -------- .../src/components/today/TodayDashboard.tsx | 555 ++++++++++++++++++ .../components/today/TodayDashboardGrid.tsx | 44 ++ .../src/components/today/TodayDonutChart.tsx | 46 +- .../today/TodayHorizontalBarChart.tsx | 83 +-- .../src/components/today/TodayKpiGrid.tsx | 78 --- .../today/TodayRadialGaugeChart.tsx | 43 +- .../src/components/today/TodaySkeleton.tsx | 14 +- .../components/today/TodayStackedBarChart.tsx | 94 +-- .../today/TodaySubscriptionKpiCard.tsx | 84 +++ .../today/TodayUpcomingAppointments.tsx | 86 +-- .../today/today-dashboard-layout.ts | 135 +++++ .../src/components/today/widget-registry.ts | 26 +- .../appointments/AppointmentScheduleGrid.tsx | 7 + frontend/src/components/ui/shared/Sidebar.tsx | 4 +- frontend/src/styles/globals.css | 9 +- frontend/src/types/today.ts | 17 +- 35 files changed, 1615 insertions(+), 678 deletions(-) create mode 100644 backend/prisma/wipe-app-data.ts create mode 100644 frontend/src/components/today/TodayCaseCompletionKpiCard.tsx create mode 100644 frontend/src/components/today/TodayChartFrame.tsx delete mode 100644 frontend/src/components/today/TodayChartsSection.tsx create mode 100644 frontend/src/components/today/TodayDashboard.tsx create mode 100644 frontend/src/components/today/TodayDashboardGrid.tsx delete mode 100644 frontend/src/components/today/TodayKpiGrid.tsx create mode 100644 frontend/src/components/today/TodaySubscriptionKpiCard.tsx create mode 100644 frontend/src/components/today/today-dashboard-layout.ts diff --git a/backend/package.json b/backend/package.json index 3fd9825..c4fcca5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "prisma:deploy": "prisma migrate deploy", "prisma:seed": "prisma db seed", "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts", + "prisma:wipe-app-data": "ts-node prisma/wipe-app-data.ts", "prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts" }, "prisma": { diff --git a/backend/prisma/wipe-app-data.ts b/backend/prisma/wipe-app-data.ts new file mode 100644 index 0000000..e3680b5 --- /dev/null +++ b/backend/prisma/wipe-app-data.ts @@ -0,0 +1,112 @@ +/** + * Dev-only: wipe all application data while keeping catalog / reference tables from seed. + * + * Preserved: organization_types, plans, features, permissions, treatment_types, + * lab_workflow_steps, prosthesis_types, prosthesis_type_steps, catalog_translations + * + * Usage: npm run prisma:wipe-app-data + */ +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; +import { existsSync, rmSync } from 'fs'; +import path from 'path'; + +const envPath = path.join(__dirname, '..', '.env'); +config({ path: envPath }); + +if (process.env.NODE_ENV === 'production') { + console.error('wipe-app-data is not allowed in production'); + process.exit(1); +} + +const prisma = new PrismaClient(); + +const CATALOG_TABLES = new Set([ + 'organization_types', + 'plans', + 'features', + 'permissions', + 'treatment_types', + 'lab_workflow_steps', + 'prosthesis_types', + 'prosthesis_type_steps', + 'catalog_translations', +]); + +// FK-safe order: children before parents where CASCADE is not enough. +const TABLES_IN_ORDER = [ + 'phone_verification_codes', + 'staff_working_hours_blocks', + 'staff_working_hours_schedules', + 'staff_invitations', + 'membership_permissions', + 'sessions', + 'lab_case_task_status_events', + 'lab_case_comments', + 'lab_case_attachments', + 'lab_case_tasks', + 'lab_case_sends', + 'lab_case_tooth_prosthesis', + 'lab_case_details', + 'lab_cases', + 'treatment_detail_attachments', + 'treatment_details', + 'treatments', + 'appointments', + 'organization_links', + 'organization_invitations', + 'patients', + 'memberships', + 'organizations', + 'users', +]; + +async function tableExists(table: string): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT to_regclass('public."${table}"')::text AS exists`, + ); + return rows[0]?.exists != null; +} + +async function main() { + console.log('🧹 Wiping application data (keeping catalog / reference tables)...'); + + const existing: string[] = []; + for (const table of TABLES_IN_ORDER) { + if (CATALOG_TABLES.has(table)) { + throw new Error(`Misconfigured wipe list includes catalog table: ${table}`); + } + if (await tableExists(table)) { + existing.push(table); + } else { + console.log(` - skipping "${table}" (does not exist yet)`); + } + } + + if (existing.length === 0) { + console.log('No application tables found. Run `prisma migrate deploy` first.'); + return; + } + + const targets = existing.map((t) => `"${t}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${targets} RESTART IDENTITY CASCADE`); + + const uploadRoot = path.join(__dirname, '..', 'uploads'); + if (existsSync(uploadRoot)) { + rmSync(uploadRoot, { recursive: true, force: true }); + console.log(' - removed local uploads/ directory'); + } + + console.log('✅ Application data wiped.'); + console.log(' Preserved catalog tables:', [...CATALOG_TABLES].sort().join(', ')); + console.log(' Register a new user / org to start fresh testing.'); +} + +main() + .catch((e) => { + console.error('❌ Wipe failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index 12a50f2..18693dd 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -29,7 +29,7 @@ export class AppointmentsController { @Get('column-providers') @ApiOperation({ summary: - 'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.', + 'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or TAB_APPOINTMENTS_EDIT, or owner.', }) columnProviders( @Query() query: ColumnProvidersQueryDto, diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index ef1040e..31cabdb 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -77,7 +77,10 @@ export class AppointmentsService { } async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) { - await this.assertCanViewAppointments(actorUserId, organizationId); + const { scopeToProvider } = await this.assertCanListAppointmentsForTreatment( + actorUserId, + organizationId, + ); const from = new Date(query.from); const to = new Date(query.to); @@ -95,6 +98,7 @@ export class AppointmentsService { organizationId, startAt: { lt: to }, endAt: { gt: from }, + ...(scopeToProvider ? { providerUserId: actorUserId } : {}), }, include: { patient: { @@ -256,18 +260,34 @@ export class AppointmentsService { return; } const names = m.permissions.map((p) => p.permission.name); - if (names.includes('TAB_APPOINTMENTS_READ')) { - return; - } - if (names.includes('TAB_TREATMENT_EDIT')) { - return; - } - if (names.includes('TAB_TREATMENT_READ')) { + if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) { return; } throw new ForbiddenException('You do not have access to appointments'); } + private async assertCanListAppointmentsForTreatment( + userId: string, + organizationId: string, + ) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) { + return { membership: m, scopeToProvider: false as const }; + } + const names = m.permissions.map((p) => p.permission.name); + const canViewSchedule = + names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT'); + const canViewTreatment = + names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT'); + if (!canViewSchedule && !canViewTreatment) { + throw new ForbiddenException('You do not have access to appointments'); + } + return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment }; + } + private async assertCanEditAppointments(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { @@ -280,9 +300,6 @@ export class AppointmentsService { if (names.includes('TAB_APPOINTMENTS_EDIT')) { return; } - if (names.includes('TAB_TREATMENT_EDIT')) { - return; - } throw new ForbiddenException('You cannot create or modify appointments'); } diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 0f97f6f..b05bb43 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -45,6 +45,7 @@ type TodayCharts = { appointmentsWeekMine?: ChartBucket[]; labTaskActivityWeek?: StackedDayBucket[]; inProgressTasksByProsthesis?: ChartBucket[]; + efficiencyReport?: ChartBucket[]; }; type TodayActions = { @@ -57,6 +58,20 @@ type TodayActions = { }>; }; +type TodaySubscriptionWidget = { + hasActivePlan: boolean; + planName: string | null; + seatsUsed: number; + seatsLimit: number | null; + seatsUnlimited: boolean; + seatsPercent: number; + periodStartAt: string; + periodEndAt: string | null; + periodTotalDays: number; + periodElapsedDays: number; + periodPercent: number; +}; + type TodayWidgets = { appointmentsToday?: { count: number }; patientsToday?: { count: number }; @@ -68,7 +83,6 @@ type TodayWidgets = { tasksInProgress?: { count: number }; importantTasks?: { count: number }; pendingConnections?: { count: number }; - seats?: { used: number; limit: number | null; unlimited: boolean }; pendingStaffInvites?: { count: number }; providersWithoutWorkingHours?: { count: number }; }; @@ -106,6 +120,7 @@ export class TodayService { const charts: TodayCharts = {}; const actions: TodayActions = {}; const tasks: Promise[] = []; + let subscription: TodaySubscriptionWidget | undefined; if (orgType === 'CLINIC') { if (this.canViewAppointments(membership.isOwner, permissionNames)) { @@ -210,8 +225,23 @@ export class TodayService { ); } + if (membership.isOwner) { + if (orgType === 'CLINIC') { + tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts)); + } + if (orgType === 'LAB') { + tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts)); + } + tasks.push( + this.buildSubscriptionWidget(organizationId, membership.organization).then( + (value) => { + subscription = value; + }, + ), + ); + } + if (this.canViewStaff(membership.isOwner, permissionNames)) { - tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets)); tasks.push(this.loadPendingStaffInvites(organizationId, widgets)); } @@ -226,6 +256,7 @@ export class TodayService { widgets, charts, actions, + ...(subscription ? { subscription } : {}), }, }; } @@ -356,7 +387,7 @@ export class TodayService { patient: { select: { firstName: true, lastName: true } }, }, orderBy: { startAt: 'asc' }, - take: 3, + take: 10, }); actions.upcomingAppointmentsToday = items.map((appointment) => ({ @@ -481,25 +512,197 @@ export class TodayService { widgets.pendingConnections = { count }; } - private async loadSeats( + private async getActiveEditAccessUserIds( organizationId: string, - plan: { maxUsers: number } | null, - widgets: TodayWidgets, + editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT', + ): Promise { + const members = await this.prisma.membership.findMany({ + where: { + organizationId, + isActive: true, + OR: [ + { isOwner: true }, + { + isOwner: false, + permissions: { + some: { permission: { name: editPermission } }, + }, + }, + ], + }, + select: { userId: true }, + }); + + return members.map((member) => member.userId); + } + + private async loadClinicEfficiencyReport( + organizationId: string, + rangeEnd: Date, + charts: TodayCharts, ) { - const used = await this.prisma.membership.count({ + const eligibleUserIds = await this.getActiveEditAccessUserIds( + 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({ + by: ['providerUserId'], + where: { + organizationId, + treatmentAt: { gte: monthStart, lt: rangeEnd }, + providerUserId: { in: eligibleUserIds }, + }, + _count: { _all: true }, + }); + + const countsByUser = new Map( + eligibleUserIds.map((userId) => [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); + } + + private async loadLabEfficiencyReport( + labOrganizationId: string, + rangeEnd: Date, + charts: TodayCharts, + ) { + const eligibleUserIds = await this.getActiveEditAccessUserIds( + 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({ + by: ['changedByUserId'], + where: { + toStatus: LabTaskStatus.COMPLETED, + changedAt: { gte: monthStart, lt: rangeEnd }, + changedByUserId: { in: eligibleUserIds }, + task: { + labCase: { + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + }, + _count: { _all: true }, + }); + + const countsByUser = new Map( + eligibleUserIds.map((userId) => [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); + } + + private async buildSubscriptionWidget( + organizationId: string, + organization: { + createdAt: Date; + plan: { name: string; maxUsers: number } | null; + }, + ): Promise { + const seatsUsed = await this.prisma.membership.count({ where: { organizationId, OR: [{ isOwner: true }, { isActive: true }], }, }); - const maxUsers = plan?.maxUsers ?? 0; - const unlimited = isUnlimitedSeats(maxUsers); + const plan = organization.plan; + const periodStartAt = organization.createdAt.toISOString(); - widgets.seats = { - used, - limit: unlimited ? null : maxUsers, - unlimited, + if (!plan) { + return { + hasActivePlan: false, + planName: null, + seatsUsed, + seatsLimit: null, + seatsUnlimited: false, + seatsPercent: 0, + periodStartAt, + periodEndAt: null, + periodTotalDays: 0, + periodElapsedDays: 0, + periodPercent: 0, + }; + } + + const maxUsers = plan.maxUsers; + const seatsUnlimited = isUnlimitedSeats(maxUsers); + const seatsLimit = seatsUnlimited ? null : maxUsers; + const seatsPercent = + seatsUnlimited || maxUsers <= 0 + ? 0 + : Math.min(100, Math.round((seatsUsed / maxUsers) * 100)); + + const durationDays = plan.name === 'trial' ? 30 : 90; + const periodEnd = new Date(organization.createdAt); + periodEnd.setDate(periodEnd.getDate() + durationDays); + const periodEndAt = periodEnd.toISOString(); + const totalMs = periodEnd.getTime() - organization.createdAt.getTime(); + const elapsedMs = Math.min( + Math.max(0, Date.now() - organization.createdAt.getTime()), + totalMs, + ); + const periodPercent = + totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0; + const periodElapsedDays = Math.min( + durationDays, + Math.floor(elapsedMs / 86_400_000), + ); + + return { + hasActivePlan: true, + planName: plan.name, + seatsUsed, + seatsLimit, + seatsUnlimited, + seatsPercent, + periodStartAt, + periodEndAt, + periodTotalDays: durationDays, + periodElapsedDays, + periodPercent, }; } @@ -885,12 +1088,7 @@ export class TodayService { 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), + ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'].includes(p), ); } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 07ed51d..2c991ec 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -15,7 +15,6 @@ "jsx": "react", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6381d7a..18936e2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -208,8 +208,17 @@ "widgetImportantTasks": "Important Tasks", "widgetPendingConnections": "Pending Connections", "widgetProvidersWithoutWorkingHours": "Providers Without Working Hours", - "widgetSeats": "Seat Usage", "widgetPendingStaffInvites": "Pending Staff Invites", + "widgetSubscription": "Subscription", + "subscriptionSeatsLabel": "Seats used", + "subscriptionSeatsRemainingLabel": "Seats left", + "subscriptionSeatsPercent": "{percent}%", + "subscriptionSeatsUnlimitedShort": "Unlimited", + "subscriptionPeriodLabel": "Plan period", + "subscriptionPeriodRemainingLabel": "Days left", + "subscriptionPeriodPercent": "{percent}%", + "subscriptionPeriodDays": "{elapsed}/{total} days", + "subscriptionNoPlan": "No active plan", "chartAppointmentsWeekAllTitle": "Appointments This Week", "chartAppointmentsWeekAllSubtitle": "All providers — last 7 days", "chartAppointmentsWeekMineTitle": "My Appointments This Week", @@ -230,6 +239,9 @@ "chartCaseCompletionTasks": "Tasks completed", "chartTasksByStepTitle": "Tasks by Workflow Step", "chartTasksByStepSubtitle": "In progress now", + "chartEfficiencyReportTitle": "Efficiency Report", + "chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days", + "chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days", "chartEmpty": "No data for this period yet.", "upcomingAppointmentsTitle": "Upcoming Today", "upcomingAppointmentsSubtitle": "Appointments not yet finished", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 41aa956..bed2fca 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -208,8 +208,17 @@ "widgetImportantTasks": "وظایف مهم", "widgetPendingConnections": "درخواست‌های اتصال در انتظار", "widgetProvidersWithoutWorkingHours": "ارائه‌دهندگان بدون ساعات کاری", - "widgetSeats": "استفاده از صندلی", "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار", + "widgetSubscription": "اشتراک", + "subscriptionSeatsLabel": "صندلی‌های استفاده‌شده", + "subscriptionSeatsRemainingLabel": "صندلی باقی‌مانده", + "subscriptionSeatsPercent": "{percent}٪", + "subscriptionSeatsUnlimitedShort": "نامحدود", + "subscriptionPeriodLabel": "دوره اشتراک", + "subscriptionPeriodRemainingLabel": "روز باقی‌مانده", + "subscriptionPeriodPercent": "{percent}٪", + "subscriptionPeriodDays": "{elapsed}/{total} روز", + "subscriptionNoPlan": "اشتراک فعال نیست", "chartAppointmentsWeekAllTitle": "نوبت‌های این هفته", "chartAppointmentsWeekAllSubtitle": "همه ارائه‌دهندگان — ۷ روز گذشته", "chartAppointmentsWeekMineTitle": "نوبت‌های من این هفته", @@ -230,6 +239,9 @@ "chartCaseCompletionTasks": "وظایف تکمیل‌شده", "chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار", "chartTasksByStepSubtitle": "در حال انجام", + "chartEfficiencyReportTitle": "گزارش کارایی", + "chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته", + "chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته", "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.", "upcomingAppointmentsTitle": "نوبت‌های پیش رو", "upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index cf5e5f4..15f9988 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -208,8 +208,17 @@ "widgetImportantTasks": "Belangrijke taken", "widgetPendingConnections": "Openstaande koppelingsverzoeken", "widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden", - "widgetSeats": "Zitplaatsgebruik", "widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen", + "widgetSubscription": "Abonnement", + "subscriptionSeatsLabel": "Gebruikte zitplaatsen", + "subscriptionSeatsRemainingLabel": "Zitplaatsen over", + "subscriptionSeatsPercent": "{percent}%", + "subscriptionSeatsUnlimitedShort": "Onbeperkt", + "subscriptionPeriodLabel": "Abonnementsperiode", + "subscriptionPeriodRemainingLabel": "Dagen over", + "subscriptionPeriodPercent": "{percent}%", + "subscriptionPeriodDays": "{elapsed}/{total} dagen", + "subscriptionNoPlan": "Geen actief abonnement", "chartAppointmentsWeekAllTitle": "Afspraken deze week", "chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen", "chartAppointmentsWeekMineTitle": "Mijn afspraken deze week", @@ -230,6 +239,9 @@ "chartCaseCompletionTasks": "Taken voltooid", "chartTasksByStepTitle": "Taken per workflowstap", "chartTasksByStepSubtitle": "Nu in uitvoering", + "chartEfficiencyReportTitle": "Efficiëntierapport", + "chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen", + "chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen", "chartEmpty": "Nog geen gegevens voor deze periode.", "upcomingAppointmentsTitle": "Komende afspraken vandaag", "upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond", diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 573c324..bcbbf40 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -189,6 +189,9 @@ export default function AppointmentsPage() { } function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } if (isViewingPastDay) { toast.showInfo(t('infoPastViewOnly')); return; @@ -205,6 +208,9 @@ export default function AppointmentsPage() { } function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } if (isViewingPastDay) { toast.showInfo(t('infoPastViewOnly')); return; diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 1d934a7..b51a2a2 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -4,18 +4,8 @@ import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; -import { - canAccessAppointmentsSection, - canViewAppointmentsTab, - canViewCases, - canViewLabCasesOrTasks, - canViewTasks, - canViewTreatment, -} from '@/components/shared/permissions'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { TodayKpiGrid } from '@/components/today/TodayKpiGrid'; -import { TodayChartsSection } from '@/components/today/TodayChartsSection'; -import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments'; +import { TodayDashboard } from '@/components/today/TodayDashboard'; import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary'; @@ -27,29 +17,10 @@ export default function TodayPage() { const orgId = currentOrganization?.id; const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); - const showNoSubscriptionNotice = - Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; - - const showUpcoming = - currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization); - const showCharts = useMemo(() => { - const orgType = currentOrganization?.type; - if (!orgType) return false; - - if (orgType === 'CLINIC') { - return ( - canAccessAppointmentsSection(currentOrganization) || - canViewAppointmentsTab(currentOrganization) || - canViewTreatment(currentOrganization) - ); - } - - return ( - canViewCases(currentOrganization) || - canViewTasks(currentOrganization) || - canViewLabCasesOrTasks(currentOrganization) - ); - }, [currentOrganization]); + const showNoSubscriptionNotice = useMemo( + () => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan, + [currentOrganization], + ); const sectionErrorMessage = t('sectionLoadError'); @@ -93,47 +64,16 @@ export default function TodayPage() { } > - - - {showUpcoming && (!error || data) ? ( - } - > -
- - {showCharts ? ( - - ) : null} -
-
- ) : showCharts && (!error || data) ? ( - } - > - - - ) : null} ); } diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 31d4684..16e1a60 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -118,8 +118,7 @@ export function canViewStaff(org: Organization | null): boolean { } /** - * Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns). - * Aligns with backend appointment mutations. + * Create/delete/book slots: owners or staff with TAB_APPOINTMENTS_EDIT only. */ export function canEditAppointments(org: Organization | null): boolean { if (!org) { @@ -131,29 +130,12 @@ export function canEditAppointments(org: Organization | null): boolean { if (org.isOwner) { return true; } - return ( - hasPermission(org, 'TAB_APPOINTMENTS_EDIT') || - hasPermission(org, 'TAB_TREATMENT_EDIT') - ); + return hasPermission(org, 'TAB_APPOINTMENTS_EDIT'); } -/** Route + sidebar: view appointments page if user can read appointments or manage treatment (column staff). */ +/** Route + sidebar: appointments tab requires TAB_APPOINTMENTS_READ or TAB_APPOINTMENTS_EDIT. */ export function canAccessAppointmentsSection(org: Organization | null): boolean { - if (!org) { - return false; - } - if (org.type !== 'CLINIC') { - return false; - } - if (org.isOwner) { - return true; - } - return ( - hasPermission(org, 'TAB_APPOINTMENTS_READ') || - hasPermission(org, 'TAB_APPOINTMENTS_EDIT') || - hasPermission(org, 'TAB_TREATMENT_EDIT') || - hasPermission(org, 'TAB_TREATMENT_READ') - ); + return canViewAppointmentsTab(org); } /** Treatment composer, scheduling columns, and saving clinical workflows */ diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/today/ChartCard.tsx index d15a29c..d51592f 100644 --- a/frontend/src/components/today/ChartCard.tsx +++ b/frontend/src/components/today/ChartCard.tsx @@ -24,8 +24,8 @@ export function ChartCard({ } return ( - -
+ +

{title}

{subtitle ? (

{subtitle}

@@ -33,11 +33,11 @@ export function ChartCard({
{isEmpty ? ( -
+

{emptyMessage}

) : ( -
{children}
+
{children}
)} ); diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/today/KpiCard.tsx index 3bb75c4..983e15a 100644 --- a/frontend/src/components/today/KpiCard.tsx +++ b/frontend/src/components/today/KpiCard.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import type { KpiCardColor } from '@/components/today/widget-registry'; @@ -20,6 +22,7 @@ interface KpiCardProps { color?: KpiCardColor; loading?: boolean; href?: string; + className?: string; } export function KpiCard({ @@ -30,10 +33,11 @@ export function KpiCard({ color = 'default', loading = false, href, + className = '', }: KpiCardProps) { const card = (

{title}

@@ -57,7 +61,10 @@ export function KpiCard({ if (href && !loading) { return ( - + {card} ); diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/today/TodayAreaChart.tsx index 9c39b0d..063d2db 100644 --- a/frontend/src/components/today/TodayAreaChart.tsx +++ b/frontend/src/components/today/TodayAreaChart.tsx @@ -9,6 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { TODAY_CHART_AXIS_COLOR, @@ -23,44 +24,46 @@ interface TodayAreaChartProps { export function TodayAreaChart({ data }: TodayAreaChartProps) { return ( - - - - - - - - - - - - String(label)} - /> - - - + + + + + + + + + + + + + String(label)} + /> + + + + ); } diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/today/TodayBarChart.tsx index 2d00030..db701e6 100644 --- a/frontend/src/components/today/TodayBarChart.tsx +++ b/frontend/src/components/today/TodayBarChart.tsx @@ -11,6 +11,7 @@ import { YAxis, } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COLORS, @@ -31,11 +32,12 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) { })); return ( - - + + + - + + ); } diff --git a/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx b/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx new file mode 100644 index 0000000..bfddf82 --- /dev/null +++ b/frontend/src/components/today/TodayCaseCompletionKpiCard.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Package } 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'; + +interface TodayCaseCompletionKpiCardProps { + completed: number; + total: number; + percent: number; +} + +export function TodayCaseCompletionKpiCard({ + completed, + total, + percent, +}: TodayCaseCompletionKpiCardProps) { + const t = useTranslations('today'); + + const percentLabel = + total > 0 ? t('chartCaseCompletionPercent', { percent }) : '—'; + + return ( + + +
+
+

{t('chartCaseCompletionTitle')}

+

+ {t('chartCaseCompletionSubtitle')} +

+
+ +
+ +
+
+ 0 ? percent : 0} + completed={completed} + total={total} + percentLabel={percentLabel} + tasksLabel={t('chartCaseCompletionTasks')} + fillColor={TODAY_CHART_COMPLETED_COLOR} + showRatio={total > 0} + /> +
+
+
+ + ); +} diff --git a/frontend/src/components/today/TodayChartFrame.tsx b/frontend/src/components/today/TodayChartFrame.tsx new file mode 100644 index 0000000..7825f18 --- /dev/null +++ b/frontend/src/components/today/TodayChartFrame.tsx @@ -0,0 +1,8 @@ +'use client'; + +import type { ReactNode } from 'react'; + +/** Fills the chart area inside a dashboard chart card (flex child). */ +export function TodayChartFrame({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/frontend/src/components/today/TodayChartsSection.tsx b/frontend/src/components/today/TodayChartsSection.tsx deleted file mode 100644 index 1b2b943..0000000 --- a/frontend/src/components/today/TodayChartsSection.tsx +++ /dev/null @@ -1,243 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { useTranslations } from 'next-intl'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { ChartCard } from '@/components/today/ChartCard'; -import { TodayAreaChart } from '@/components/today/TodayAreaChart'; -import { TodayBarChart } from '@/components/today/TodayBarChart'; -import { - formatTodayChartDayLabel, - mapWeekChartBuckets, - useTodayDayLabelFormatter, -} from '@/components/today/chart-day-labels'; -import { TodayDonutChart } from '@/components/today/TodayDonutChart'; -import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; -import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; -import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart'; -import { ChartCardSkeleton } from '@/components/today/TodaySkeleton'; -import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; -import type { TodaySummaryCharts } from '@/types/today'; - -interface TodayChartsSectionProps { - charts: TodaySummaryCharts; - loading?: boolean; - isInitialLoad?: boolean; - className?: string; - /** When true, chart cards render as siblings (no outer grid wrapper). */ - embedded?: boolean; -} - -export function TodayChartsSection({ - charts, - loading = false, - isInitialLoad = false, - className = '', - embedded = false, -}: TodayChartsSectionProps) { - const t = useTranslations('today'); - const dayLabelFormatter = useTodayDayLabelFormatter(); - const { currentOrganization } = useAuth(); - const orgType = currentOrganization?.type; - - const showAppointmentsByProvider = - orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined; - const showAppointmentsWeekAll = - orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined; - const showAppointmentsWeekMine = - orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined; - const showTreatmentMix = - orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined; - const showCaseCompletion = - orgType === 'LAB' && charts.caseCompletion !== undefined; - const showTasksByStep = - orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined; - const showLabTaskActivityWeek = - orgType === 'LAB' && charts.labTaskActivityWeek !== undefined; - const showInProgressTasksByProsthesis = - orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined; - - const visibleChartCount = - Number(showAppointmentsByProvider) + - Number(showAppointmentsWeekAll) + - Number(showAppointmentsWeekMine) + - Number(showTreatmentMix) + - Number(showCaseCompletion) + - Number(showTasksByStep) + - Number(showLabTaskActivityWeek) + - Number(showInProgressTasksByProsthesis); - - const appointmentsByProviderData = charts.appointmentsByProvider ?? []; - const appointmentsWeekAllData = useMemo( - () => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter), - [charts.appointmentsWeekAll, dayLabelFormatter], - ); - const appointmentsWeekMineData = useMemo( - () => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter), - [charts.appointmentsWeekMine, dayLabelFormatter], - ); - const treatmentData = charts.treatmentMixWeek ?? []; - const tasksData = charts.tasksByWorkflowStep ?? []; - const labTaskActivityData = useMemo( - () => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter), - [charts.labTaskActivityWeek, dayLabelFormatter], - ); - const prosthesisData = charts.inProgressTasksByProsthesis ?? []; - const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 }; - - const formatDayLabel = (code: string) => - formatTodayChartDayLabel(code, dayLabelFormatter); - - if (visibleChartCount === 0) { - return null; - } - - if (isInitialLoad) { - const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => ( - - )); - - if (embedded) { - return <>{skeletons}; - } - - return ( -
{skeletons}
- ); - } - - const gridClass = - visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1'; - - const chartCards = ( - <> - {showAppointmentsWeekAll ? ( - row.count === 0)} - emptyMessage={t('chartEmpty')} - > - - - ) : null} - - {showAppointmentsWeekMine ? ( - row.count === 0)} - emptyMessage={t('chartEmpty')} - > - - - ) : null} - - {showLabTaskActivityWeek ? ( - row.completed === 0 && row.received === 0, - )} - emptyMessage={t('chartEmpty')} - > - - - ) : null} - - {showInProgressTasksByProsthesis ? ( - - - prosthesisData.find((row) => row.code === code)?.label ?? code - } - colorForCode={(code, index) => prosthesisTypeColor(code, index)} - swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)} - variant="pie" - sideLegend - /> - - ) : null} - - {showAppointmentsByProvider ? ( - - - - ) : null} - - {showTreatmentMix ? ( - - treatmentTypeColor(code, index)} - /> - - ) : null} - - {showCaseCompletion ? ( - - - - ) : null} - - {showTasksByStep ? ( - - - - ) : null} - - ); - - if (embedded) { - return chartCards; - } - - return ( -
- {chartCards} -
- ); -} diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/today/TodayDashboard.tsx new file mode 100644 index 0000000..3bf0af3 --- /dev/null +++ b/frontend/src/components/today/TodayDashboard.tsx @@ -0,0 +1,555 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { + canViewAppointmentsTab, + canViewCases, + canViewLabCasesOrTasks, + canViewTasks, + canViewTreatment, +} from '@/components/shared/permissions'; +import { KpiCard } from '@/components/today/KpiCard'; +import { ChartCard } from '@/components/today/ChartCard'; +import { TodayAreaChart } from '@/components/today/TodayAreaChart'; +import { TodayBarChart } from '@/components/today/TodayBarChart'; +import { + formatTodayChartDayLabel, + mapWeekChartBuckets, + useTodayDayLabelFormatter, +} from '@/components/today/chart-day-labels'; +import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; +import { TodayDonutChart } from '@/components/today/TodayDonutChart'; +import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; +import { TodayCaseCompletionKpiCard } from '@/components/today/TodayCaseCompletionKpiCard'; +import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart'; +import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard'; +import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments'; +import { + ChartCardSkeleton, + KpiCardSkeleton, + ListRowSkeleton, +} from '@/components/today/TodaySkeleton'; +import { + TODAY_DASHBOARD_LAYOUT, + type TodayDashboardCell, +} from '@/components/today/today-dashboard-layout'; +import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; +import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import type { + TodaySubscriptionSnapshot, + TodaySummaryActions, + TodaySummaryCharts, + TodaySummaryWidgets, +} from '@/types/today'; + +interface TodayDashboardProps { + widgets: TodaySummaryWidgets; + charts: TodaySummaryCharts; + actions: TodaySummaryActions; + subscription?: TodaySubscriptionSnapshot; + loading?: boolean; + isInitialLoad?: boolean; + hasError?: boolean; +} + +export function TodayDashboard({ + widgets, + charts, + actions, + subscription, + loading = false, + isInitialLoad = false, + hasError = false, +}: TodayDashboardProps) { + const t = useTranslations('today'); + const dayLabelFormatter = useTodayDayLabelFormatter(); + const { currentOrganization } = useAuth(); + const orgType = currentOrganization?.type; + const isOwner = Boolean(currentOrganization?.isOwner); + + const showUpcoming = + orgType === 'CLINIC' && currentOrganization && canViewTreatment(currentOrganization); + + const showCharts = useMemo(() => { + if (!orgType || !currentOrganization) return false; + if (orgType === 'CLINIC') { + return ( + canViewAppointmentsTab(currentOrganization) || + canViewTreatment(currentOrganization) + ); + } + return ( + canViewCases(currentOrganization) || + canViewTasks(currentOrganization) || + canViewLabCasesOrTasks(currentOrganization) + ); + }, [currentOrganization, orgType]); + + const kpiDefinitions = isInitialLoad + ? getEligibleTodayKpis(currentOrganization) + : getVisibleTodayKpis(currentOrganization, widgets); + + const showSubscriptionCard = isOwner && (isInitialLoad || Boolean(subscription)); + + const showCaseCompletionCard = + orgType === 'LAB' && + Boolean(currentOrganization && canViewCases(currentOrganization)) && + (isInitialLoad || charts.caseCompletion !== undefined); + + const cells = useMemo(() => { + if (isInitialLoad) { + return buildSkeletonCells({ + kpiDefinitions, + showSubscriptionCard, + showCaseCompletionCard, + showUpcoming: Boolean(showUpcoming), + showCharts, + orgType, + isOwner, + charts, + }); + } + + return buildDashboardCells({ + t, + dayLabelFormatter, + widgets, + charts, + actions, + subscription, + kpiDefinitions, + showSubscriptionCard: showSubscriptionCard && Boolean(subscription), + showCaseCompletionCard: + showCaseCompletionCard && charts.caseCompletion !== undefined, + showUpcoming: Boolean(showUpcoming), + showCharts, + orgType, + isOwner, + currentOrganization, + }); + }, [ + isInitialLoad, + kpiDefinitions, + showSubscriptionCard, + showCaseCompletionCard, + showUpcoming, + showCharts, + orgType, + isOwner, + charts, + t, + dayLabelFormatter, + widgets, + actions, + subscription, + currentOrganization, + ]); + + if (hasError && !loading && cells.length === 0) { + return null; + } + + if (!loading && !hasError && cells.length === 0) { + return ( +
+

{t('noWidgets')}

+
+ ); + } + + return ; +} + +function buildSkeletonCells(options: { + kpiDefinitions: ReturnType; + showSubscriptionCard: boolean; + showCaseCompletionCard: boolean; + showUpcoming: boolean; + showCharts: boolean; + orgType?: 'CLINIC' | 'LAB'; + isOwner: boolean; + charts: TodaySummaryCharts; +}): TodayDashboardCell[] { + const cells: TodayDashboardCell[] = []; + + if (options.showCharts) { + const chartCount = countVisibleCharts(options.charts, options.orgType, options.isOwner); + for (let index = 0; index < Math.min(chartCount, 4); index += 1) { + cells.push({ + id: `chart-skeleton-${index}`, + layout: TODAY_DASHBOARD_LAYOUT.chart, + content: , + }); + } + } + + if (options.showUpcoming) { + cells.push({ + id: 'upcoming-skeleton', + layout: TODAY_DASHBOARD_LAYOUT.upcoming, + content: ( +
+
+
+
+
+
+ {[0, 1].map((key) => ( + + ))} +
+
+ ), + }); + } + + if (options.showSubscriptionCard) { + cells.push({ + id: 'subscription-skeleton', + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: , + }); + } + + if (options.showCaseCompletionCard) { + cells.push({ + id: 'case-completion-skeleton', + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: , + }); + } + + for (const definition of options.kpiDefinitions) { + cells.push({ + id: `kpi-skeleton-${definition.key}`, + layout: TODAY_DASHBOARD_LAYOUT.kpi, + content: , + }); + } + + return cells; +} + +function buildDashboardCells(options: { + t: ReturnType>; + dayLabelFormatter: ReturnType; + widgets: TodaySummaryWidgets; + charts: TodaySummaryCharts; + actions: TodaySummaryActions; + subscription?: TodaySubscriptionSnapshot; + kpiDefinitions: ReturnType; + showSubscriptionCard: boolean; + showCaseCompletionCard: boolean; + showUpcoming: boolean; + showCharts: boolean; + orgType?: 'CLINIC' | 'LAB'; + isOwner: boolean; + currentOrganization: ReturnType['currentOrganization']; +}): TodayDashboardCell[] { + const cells: TodayDashboardCell[] = []; + const formatDayLabel = (code: string) => + formatTodayChartDayLabel(code, options.dayLabelFormatter); + + if (options.showCharts) { + cells.push( + ...buildChartCells({ + t: options.t, + charts: options.charts, + orgType: options.orgType, + isOwner: options.isOwner, + formatDayLabel, + dayLabelFormatter: options.dayLabelFormatter, + }), + ); + } + + if (options.showUpcoming) { + cells.push({ + id: 'upcoming-appointments', + layout: TODAY_DASHBOARD_LAYOUT.upcoming, + content: ( + + ), + }); + } + + if (options.showSubscriptionCard && options.subscription) { + cells.push({ + id: 'subscription', + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: , + }); + } + + if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) { + const caseCompletion = options.charts.caseCompletion; + cells.push({ + id: 'case-completion', + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: ( + + ), + }); + } + + for (const definition of options.kpiDefinitions) { + const value = definition.formatValue(options.widgets) ?? '—'; + const subtitle = definition.formatSubtitle?.(options.widgets); + + cells.push({ + id: `kpi-${definition.key}`, + layout: TODAY_DASHBOARD_LAYOUT.kpi, + content: ( + + ), + }); + } + + return cells; +} + +function buildChartCells(options: { + t: ReturnType>; + charts: TodaySummaryCharts; + orgType?: 'CLINIC' | 'LAB'; + isOwner: boolean; + formatDayLabel: (code: string) => string; + dayLabelFormatter: ReturnType; +}): TodayDashboardCell[] { + const { t, charts, orgType, isOwner } = options; + const cells: TodayDashboardCell[] = []; + const tallChart = TODAY_DASHBOARD_LAYOUT.chart; + const mediumChart = TODAY_DASHBOARD_LAYOUT.chartMedium; + + const appointmentsWeekAllData = mapWeekChartBuckets( + charts.appointmentsWeekAll ?? [], + options.dayLabelFormatter, + ); + const appointmentsWeekMineData = mapWeekChartBuckets( + charts.appointmentsWeekMine ?? [], + options.dayLabelFormatter, + ); + const labTaskActivityData = mapWeekChartBuckets( + charts.labTaskActivityWeek ?? [], + options.dayLabelFormatter, + ); + + if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) { + cells.push({ + id: 'chart-appointments-week-all', + layout: tallChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + if (orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined) { + cells.push({ + id: 'chart-appointments-week-mine', + layout: tallChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) { + cells.push({ + id: 'chart-lab-task-activity', + layout: tallChart, + content: ( + row.completed === 0 && row.received === 0, + )} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + const prosthesisData = charts.inProgressTasksByProsthesis ?? []; + if (orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined) { + cells.push({ + id: 'chart-prosthesis-mix', + layout: tallChart, + content: ( + + + prosthesisData.find((row) => row.code === code)?.label ?? code + } + colorForCode={(code, index) => prosthesisTypeColor(code, index)} + swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)} + variant="pie" + sideLegend + /> + + ), + }); + } + + const efficiencyReportData = charts.efficiencyReport ?? []; + if ( + isOwner && + charts.efficiencyReport !== undefined && + efficiencyReportData.length >= 2 + ) { + cells.push({ + id: 'chart-efficiency-report', + layout: tallChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + efficiencyReportData.find((row) => row.code === code)?.label ?? code + } + variant="pie" + sideLegend + /> + + ), + }); + } + + const appointmentsByProviderData = charts.appointmentsByProvider ?? []; + if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) { + cells.push({ + id: 'chart-appointments-by-provider', + layout: mediumChart, + content: ( + + + + ), + }); + } + + const treatmentData = charts.treatmentMixWeek ?? []; + if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) { + cells.push({ + id: 'chart-treatment-mix', + layout: mediumChart, + content: ( + + treatmentTypeColor(code, index)} + /> + + ), + }); + } + + const tasksData = charts.tasksByWorkflowStep ?? []; + if (orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined) { + cells.push({ + id: 'chart-tasks-by-step', + layout: mediumChart, + content: ( + + + + ), + }); + } + + return cells; +} + +function countVisibleCharts( + charts: TodaySummaryCharts, + orgType?: 'CLINIC' | 'LAB', + isOwner = false, +): number { + let count = 0; + if (orgType === 'CLINIC') { + count += charts.appointmentsWeekAll !== undefined ? 1 : 0; + count += charts.appointmentsWeekMine !== undefined ? 1 : 0; + count += charts.appointmentsByProvider !== undefined ? 1 : 0; + count += charts.treatmentMixWeek !== undefined ? 1 : 0; + } + if (orgType === 'LAB') { + count += charts.labTaskActivityWeek !== undefined ? 1 : 0; + count += charts.inProgressTasksByProsthesis !== undefined ? 1 : 0; + count += charts.tasksByWorkflowStep !== undefined ? 1 : 0; + } + if ( + isOwner && + charts.efficiencyReport !== undefined && + (charts.efficiencyReport?.length ?? 0) >= 2 + ) { + count += 1; + } + return count; +} diff --git a/frontend/src/components/today/TodayDashboardGrid.tsx b/frontend/src/components/today/TodayDashboardGrid.tsx new file mode 100644 index 0000000..ff4ddb7 --- /dev/null +++ b/frontend/src/components/today/TodayDashboardGrid.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useMemo, type CSSProperties } from 'react'; +import { + packDashboardCells, + packedCellClassName, + TODAY_DASHBOARD_GRID_CLASS, + type TodayDashboardCell, +} from '@/components/today/today-dashboard-layout'; + +interface TodayDashboardGridProps { + cells: TodayDashboardCell[]; + loading?: boolean; +} + +export function TodayDashboardGrid({ cells, loading = false }: TodayDashboardGridProps) { + const packed = useMemo(() => packDashboardCells(cells), [cells]); + + if (packed.length === 0) { + return null; + } + + return ( +
+ {packed.map((cell) => ( +
+
{cell.content}
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/today/TodayDonutChart.tsx b/frontend/src/components/today/TodayDonutChart.tsx index b7acc9e..63d4efa 100644 --- a/frontend/src/components/today/TodayDonutChart.tsx +++ b/frontend/src/components/today/TodayDonutChart.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from 'react'; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import { TODAY_CHART_COLORS, TODAY_CHART_TOOLTIP_STYLE, @@ -44,8 +45,9 @@ export function TodayDonutChart({ const outerRadius = sideLegend ? 100 : 92; const chart = ( - - + + + - + + ); if (!sideLegend) { @@ -80,9 +83,9 @@ export function TodayDonutChart({ const legendInset = 'px-12'; return ( -
-
-
+
+
+
{chartData.map((entry, index) => ( -
+
{chartData.map((entry) => (
-
- {chart} +
+ + + + {chartData.map((entry, index) => ( + + ))} + + { + const row = item?.payload as TodayChartBucket | undefined; + return [value, row ? labelForCode(row.code) : '']; + }} + /> + +
); diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/today/TodayHorizontalBarChart.tsx index 8b225ee..fa43034 100644 --- a/frontend/src/components/today/TodayHorizontalBarChart.tsx +++ b/frontend/src/components/today/TodayHorizontalBarChart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { TODAY_CHART_AXIS_COLOR, @@ -29,46 +30,48 @@ export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) })); return ( - - - - - - { - const row = payload?.[0]?.payload as TodayChartBucket | undefined; - return row?.label ?? ''; - }} - /> - - {chartData.map((entry, index) => ( - - ))} - - - + + + + + + + { + const row = payload?.[0]?.payload as TodayChartBucket | undefined; + return row?.label ?? ''; + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + ); } diff --git a/frontend/src/components/today/TodayKpiGrid.tsx b/frontend/src/components/today/TodayKpiGrid.tsx deleted file mode 100644 index 09747b4..0000000 --- a/frontend/src/components/today/TodayKpiGrid.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { KpiCard } from '@/components/today/KpiCard'; -import { KpiCardSkeleton } from '@/components/today/TodaySkeleton'; -import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; -import type { TodaySummaryWidgets } from '@/types/today'; - -interface TodayKpiGridProps { - widgets: TodaySummaryWidgets; - loading?: boolean; - isInitialLoad?: boolean; - hasError?: boolean; -} - -export function TodayKpiGrid({ - widgets, - loading = false, - isInitialLoad = false, - hasError = false, -}: TodayKpiGridProps) { - const t = useTranslations('today'); - const { currentOrganization } = useAuth(); - const definitions = isInitialLoad - ? getEligibleTodayKpis(currentOrganization) - : getVisibleTodayKpis(currentOrganization, widgets); - - if (hasError && !loading && definitions.length === 0) { - return null; - } - - if (!loading && !hasError && definitions.length === 0) { - return ( -
-

{t('noWidgets')}

-
- ); - } - - if (isInitialLoad) { - const skeletonCount = Math.max(getEligibleTodayKpis(currentOrganization).length, 4); - return ( -
- {Array.from({ length: skeletonCount }, (_, index) => ( - - ))} -
- ); - } - - 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/TodayRadialGaugeChart.tsx b/frontend/src/components/today/TodayRadialGaugeChart.tsx index 147dec9..8c49228 100644 --- a/frontend/src/components/today/TodayRadialGaugeChart.tsx +++ b/frontend/src/components/today/TodayRadialGaugeChart.tsx @@ -1,6 +1,11 @@ 'use client'; -import { RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts'; +import { + PolarAngleAxis, + RadialBar, + RadialBarChart, + ResponsiveContainer, +} from 'recharts'; import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme'; @@ -10,6 +15,9 @@ interface TodayRadialGaugeChartProps { total: number; percentLabel: string; tasksLabel: string; + size?: 'sm' | 'md'; + fillColor?: string; + showRatio?: boolean; } export function TodayRadialGaugeChart({ @@ -18,35 +26,48 @@ export function TodayRadialGaugeChart({ total, percentLabel, tasksLabel, + size = 'md', + fillColor = TODAY_CHART_PRIMARY_COLOR, + showRatio = true, }: TodayRadialGaugeChartProps) { + const isCompact = size === 'sm'; const clamped = Math.max(0, Math.min(100, percent)); - const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }]; + const data = [{ name: 'progress', value: clamped, fill: fillColor }]; return ( -
+
+ -
- {percentLabel} - {tasksLabel} - {total > 0 ? ( - +
+ + {percentLabel} + + + {tasksLabel} + + {showRatio && total > 0 ? ( + {completed}/{total} ) : null} diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/today/TodaySkeleton.tsx index 61cb7d5..c49a6f8 100644 --- a/frontend/src/components/today/TodaySkeleton.tsx +++ b/frontend/src/components/today/TodaySkeleton.tsx @@ -11,22 +11,24 @@ export function SkeletonBlock({ className = '' }: SkeletonBlockProps) { ); } -export function KpiCardSkeleton() { +export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) { return ( -
+
- - + + {!tall ? : null}
); } export function ChartCardSkeleton() { return ( -
+
- +
); } diff --git a/frontend/src/components/today/TodayStackedBarChart.tsx b/frontend/src/components/today/TodayStackedBarChart.tsx index dca7293..0b0a19a 100644 --- a/frontend/src/components/today/TodayStackedBarChart.tsx +++ b/frontend/src/components/today/TodayStackedBarChart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; import type { TodayStackedDayBucket } from '@/types/today'; import { TODAY_CHART_AXIS_COLOR, @@ -38,50 +39,53 @@ export function TodayStackedBarChart({ })); return ( - - - - - - { - const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined; - return row ? formatDayLabel(row.code) : ''; - }} - /> - - - - - + + + + + + + { + const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined; + return row ? formatDayLabel(row.code) : ''; + }} + /> + + + + + + ); } diff --git a/frontend/src/components/today/TodaySubscriptionKpiCard.tsx b/frontend/src/components/today/TodaySubscriptionKpiCard.tsx new file mode 100644 index 0000000..ca0fce1 --- /dev/null +++ b/frontend/src/components/today/TodaySubscriptionKpiCard.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { CreditCard } from 'lucide-react'; +import { Link } from '@/i18n/navigation'; +import { Card } from '@/components/ui/shared/Card'; +import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import type { TodaySubscriptionSnapshot } from '@/types/today'; + +interface TodaySubscriptionKpiCardProps { + subscription: TodaySubscriptionSnapshot; +} + +const PERIOD_GAUGE_COLOR = '#e1bc72'; + +export function TodaySubscriptionKpiCard({ subscription }: TodaySubscriptionKpiCardProps) { + const t = useTranslations('today'); + + const seatsRatioTotal = subscription.seatsUnlimited + ? 0 + : subscription.seatsLimit ?? 0; + + const seatsPercentLabel = + subscription.seatsUnlimited || !subscription.hasActivePlan + ? String(subscription.seatsUsed) + : t('subscriptionSeatsPercent', { percent: subscription.seatsPercent }); + + const seatsTasksLabel = subscription.seatsUnlimited + ? t('subscriptionSeatsUnlimitedShort') + : t('subscriptionSeatsLabel'); + + const periodPercentLabel = subscription.hasActivePlan + ? t('subscriptionPeriodPercent', { percent: subscription.periodPercent }) + : '—'; + + return ( + + +
+
+

{t('widgetSubscription')}

+ {subscription.planName ? ( +

+ {subscription.planName} +

+ ) : ( +

{t('subscriptionNoPlan')}

+ )} +
+ +
+ +
+ 0} + /> + +
+
+ + ); +} diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/today/TodayUpcomingAppointments.tsx index b6e5d97..d62fa49 100644 --- a/frontend/src/components/today/TodayUpcomingAppointments.tsx +++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx @@ -22,8 +22,6 @@ interface TodayUpcomingAppointmentsProps { isInitialLoad?: boolean; } -const MAX_VISIBLE = 3; - export function TodayUpcomingAppointments({ actions, loading = false, @@ -48,11 +46,11 @@ export function TodayUpcomingAppointments({ return null; } - const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE); + const appointments = actions.upcomingAppointmentsToday ?? []; if (isInitialLoad) { return ( - +
@@ -67,7 +65,7 @@ export function TodayUpcomingAppointments({ } return ( - +

@@ -88,44 +86,48 @@ export function TodayUpcomingAppointments({

{t('noUpcomingAppointments')}

) : ( -
    - {appointments.map((appointment) => { - const start = new Date(appointment.startAt); - const end = new Date(appointment.endAt); - const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`; - const purposeIndex = treatmentCatalog.findIndex((entry) => entry.code === appointment.purpose); - const purposeTextColor = treatmentTypeColor( - appointment.purpose, - purposeIndex < 0 ? 0 : purposeIndex, - ); - const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog); +
    +
      + {appointments.map((appointment) => { + const start = new Date(appointment.startAt); + const end = new Date(appointment.endAt); + const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`; + const purposeIndex = treatmentCatalog.findIndex( + (entry) => entry.code === appointment.purpose, + ); + const purposeTextColor = treatmentTypeColor( + appointment.purpose, + purposeIndex < 0 ? 0 : purposeIndex, + ); + const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog); - return ( -
    • - -
      -

      - {appointment.patientName} -

      -

      - {timeLabel} - {appointment.purpose ? ( - · {purposeDisplay} - ) : null} -

      -
      - - -
    • - ); - })} -
    + return ( +
  • + +
    +

    + {appointment.patientName} +

    +

    + {timeLabel} + {appointment.purpose ? ( + · {purposeDisplay} + ) : null} +

    +
    + + +
  • + ); + })} +
+
)}
); diff --git a/frontend/src/components/today/today-dashboard-layout.ts b/frontend/src/components/today/today-dashboard-layout.ts new file mode 100644 index 0000000..3061bc2 --- /dev/null +++ b/frontend/src/components/today/today-dashboard-layout.ts @@ -0,0 +1,135 @@ +import type { ReactNode } from 'react'; + +/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */ +export type TodayDashboardWidth = 1 | 2; +export type TodayDashboardHeight = 1 | 2 | 3; + +export interface TodayDashboardLayout { + width: TodayDashboardWidth; + height: TodayDashboardHeight; +} + +/** Shared layout presets — assign when registering a dashboard widget. */ +export const TODAY_DASHBOARD_LAYOUT = { + kpi: { width: 1, height: 1 }, + subscription: { width: 1, height: 2 }, + upcoming: { width: 2, height: 3 }, + /** Tall charts: area, stacked bar, pie with side legend */ + chart: { width: 2, height: 3 }, + /** Medium charts: horizontal bar, vertical bar, radial gauge */ + chartMedium: { width: 2, height: 2 }, +} as const satisfies Record; + +export interface TodayDashboardCell { + id: string; + layout: TodayDashboardLayout; + content: ReactNode; +} + +export interface PackedDashboardCell extends TodayDashboardCell { + gridColumn: string; + gridRow: string; +} + +export function compareDashboardLayout( + a: TodayDashboardLayout, + b: TodayDashboardLayout, +): number { + if (a.width !== b.width) return a.width - b.width; + return a.height - b.height; +} + +export function sortDashboardCells( + cells: T[], +): T[] { + return [...cells].sort((a, b) => { + const byLayout = compareDashboardLayout(a.layout, b.layout); + if (byLayout !== 0) return byLayout; + return a.id.localeCompare(b.id); + }); +} + +/** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */ +export function allowedStartColumns( + width: number, + columns: number, +): number[] { + if (width <= 1) { + return Array.from({ length: columns }, (_, index) => index); + } + + if (width === 2 && columns === 4) { + return [0, 2]; + } + + return Array.from({ length: columns - width + 1 }, (_, index) => index); +} + +/** + * First-fit placement in ascending layout order (top-left scan). + * Multi-column widgets may only start at aligned column pairs (1–2 or 3–4 on a 4-col grid). + */ +export function packDashboardCells( + cells: TodayDashboardCell[], + columns = 4, +): PackedDashboardCell[] { + const sorted = sortDashboardCells(cells); + const occupied = new Set(); + + function canPlace(row: number, col: number, width: number, height: number): boolean { + if (col + width > columns) return false; + for (let r = row; r < row + height; r += 1) { + for (let c = col; c < col + width; c += 1) { + if (occupied.has(`${r}-${c}`)) return false; + } + } + return true; + } + + function mark(row: number, col: number, width: number, height: number) { + for (let r = row; r < row + height; r += 1) { + for (let c = col; c < col + width; c += 1) { + occupied.add(`${r}-${c}`); + } + } + } + + const placed: PackedDashboardCell[] = []; + + for (const cell of sorted) { + const { width, height } = cell.layout; + let found = false; + const startColumns = allowedStartColumns(width, columns); + + for (let row = 0; !found; row += 1) { + for (const col of startColumns) { + if (!canPlace(row, col, width, height)) continue; + mark(row, col, width, height); + placed.push({ + ...cell, + gridColumn: `${col + 1} / span ${width}`, + gridRow: `${row + 1} / span ${height}`, + }); + found = true; + break; + } + } + } + + return placed; +} + +export function packedCellClassName(layout: TodayDashboardLayout): string { + const rowSpan = + layout.height === 3 ? 'row-span-3' : layout.height === 2 ? 'row-span-2' : 'row-span-1'; + + const colSpan = + layout.width === 2 + ? 'col-span-2 max-sm:col-span-1' + : 'col-span-1'; + + return `${colSpan} ${rowSpan} min-h-0 min-w-0 overflow-hidden flex flex-col max-lg:${colSpan}`; +} + +export const TODAY_DASHBOARD_GRID_CLASS = + 'today-dashboard-grid grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4'; diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts index 8c9cf18..50b7a83 100644 --- a/frontend/src/components/today/widget-registry.ts +++ b/frontend/src/components/today/widget-registry.ts @@ -11,8 +11,8 @@ import { } from 'lucide-react'; import type { Organization } from '@/types/organization'; import { - canAccessAppointmentsSection, canEditStaff, + canViewAppointmentsTab, canViewCases, canViewStaff, canViewTasks, @@ -67,7 +67,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ color: 'blue', orgTypes: ['CLINIC'], href: '/appointments', - isVisible: (org) => canAccessAppointmentsSection(org), + isVisible: (org) => canViewAppointmentsTab(org), formatValue: (widgets) => { const count = countWidget(widgets, 'appointmentsToday'); return count === null ? null : String(count); @@ -80,7 +80,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ color: 'green', orgTypes: ['CLINIC'], href: '/patients', - isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org), + isVisible: (org) => canViewPatients(org) || canViewAppointmentsTab(org), formatValue: (widgets) => { const count = countWidget(widgets, 'patientsToday'); return count === null ? null : String(count); @@ -203,26 +203,6 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ return count === null ? null : String(count); }, }, - { - key: 'seats', - titleKey: 'widgetSeats', - icon: UserCog, - color: 'default', - orgTypes: ['CLINIC', 'LAB'], - href: '/staff', - 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', diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 5cabee7..9d07db9 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -160,6 +160,9 @@ export function AppointmentScheduleGrid({ }); return; } + if (!canBook) { + return; + } onAppointmentClick?.(apt); } @@ -373,6 +376,10 @@ export function AppointmentScheduleGrid({ treatmentCatalog={treatmentCatalog} anchorRect={overlapPopover.anchorRect} onSelect={(apt) => { + if (!canBook) { + setOverlapPopover(null); + return; + } const provider = providers.find((p) => p.userId === apt.providerUserId); if ( provider && diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index d5a3799..4949e6a 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -18,7 +18,7 @@ import type { OrgTypeName } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount'; import { - canAccessAppointmentsSection, + canViewAppointmentsTab, canViewCases, canViewTasks, canViewTab, @@ -73,7 +73,7 @@ function Sidebar() { return false; } if (item.path === '/appointments') { - return canAccessAppointmentsSection(currentOrganization); + return canViewAppointmentsTab(currentOrganization); } if (item.path === '/cases') { return canViewCases(currentOrganization); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 3e7998b..f988811 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -121,7 +121,7 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - + --today-grid-unit: 5.75rem; --color-background-primary: #000c1c; --color-background-secondary: #0a1520; --color-background-card: #14253d; @@ -272,6 +272,13 @@ select option { border-radius: var(--radius-lg); } +@media (min-width: 1024px) { + .today-dashboard-grid .today-dashboard-cell { + grid-column: var(--today-gc); + grid-row: var(--today-gr); + } +} + :root[data-theme='dark'] .surface-card, :root:not([data-theme='light']) .surface-card { background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary)); diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index 67faaf3..b932019 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -36,6 +36,7 @@ export type TodaySummaryCharts = { appointmentsWeekMine?: TodayChartBucket[]; labTaskActivityWeek?: TodayStackedDayBucket[]; inProgressTasksByProsthesis?: TodayChartBucket[]; + efficiencyReport?: TodayChartBucket[]; }; export type TodayWidgetKey = @@ -49,10 +50,23 @@ export type TodayWidgetKey = | 'tasksInProgress' | 'importantTasks' | 'pendingConnections' - | 'seats' | 'pendingStaffInvites' | 'providersWithoutWorkingHours'; +export type TodaySubscriptionSnapshot = { + hasActivePlan: boolean; + planName: string | null; + seatsUsed: number; + seatsLimit: number | null; + seatsUnlimited: boolean; + seatsPercent: number; + periodStartAt: string; + periodEndAt: string | null; + periodTotalDays: number; + periodElapsedDays: number; + periodPercent: number; +}; + export type TodaySummaryWidgets = Partial< Record< TodayWidgetKey, @@ -68,6 +82,7 @@ export interface TodaySummaryData { widgets: TodaySummaryWidgets; charts: TodaySummaryCharts; actions: TodaySummaryActions; + subscription?: TodaySubscriptionSnapshot; } export interface TodaySummaryResponse {