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/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/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/dto/today-summary-query.dto.ts b/backend/src/modules/today/dto/today-summary-query.dto.ts new file mode 100644 index 0000000..9beef55 --- /dev/null +++ b/backend/src/modules/today/dto/today-summary-query.dto.ts @@ -0,0 +1,33 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +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; + + @ApiPropertyOptional({ + description: + 'Client UTC offset in minutes (same sign as Date.getTimezoneOffset negated). Used for hourly buckets.', + example: 210, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(-840) + @Max(840) + utcOffsetMinutes?: number; +} diff --git a/backend/src/modules/today/today.controller.ts b/backend/src/modules/today/today.controller.ts new file mode 100644 index 0000000..8135719 --- /dev/null +++ b/backend/src/modules/today/today.controller.ts @@ -0,0 +1,31 @@ +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; language?: string } }, + ) { + const organizationId = this.todayService.getOrganizationIdFromUser(req.user); + return this.todayService.getSummary( + req.user.id, + organizationId, + query, + req.user.language, + ); + } +} diff --git a/backend/src/modules/today/today.module.ts b/backend/src/modules/today/today.module.ts new file mode 100644 index 0000000..dd6988f --- /dev/null +++ b/backend/src/modules/today/today.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { StaffModule } from '../staff/staff.module'; +import { TodayController } from './today.controller'; +import { TodayService } from './today.service'; + +@Module({ + imports: [StaffModule], + 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..c547d33 --- /dev/null +++ b/backend/src/modules/today/today.service.ts @@ -0,0 +1,1311 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { + isUnlimitedSeats, + normalizeTabPermissions, +} from '../../common/permissions'; +import { + OrganizationTypeName, + ownerPermissionsForOrgType, +} from '../../common/organization-type'; +import { + CatalogLabelService, + normalizeCatalogLocale, + type CatalogLocale, +} from '../catalog/catalog-label.service'; +import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; +import { TodaySummaryQueryDto } from './dto/today-summary-query.dto'; + +type ChartBucket = { code: string; label: string; count: number }; + +type StackedDayBucket = { + code: string; + label: string; + completed: number; + received: number; +}; + +type CompletionGaugeChart = { + completed: number; + total: number; + percent: number; +}; + +type PartnerCasesBucket = { + code: string; + label: string; + completed: number; + pending: number; +}; + +type TodayCharts = { + treatmentMixWeek?: ChartBucket[]; + tasksByProsthesis?: ChartBucket[]; + appointmentsByProvider?: ChartBucket[]; + caseCompletion?: CompletionGaugeChart; + treatmentPlanCompletion?: CompletionGaugeChart; + appointmentsWeekAll?: ChartBucket[]; + appointmentsWeekMine?: ChartBucket[]; + labTaskActivityWeek?: StackedDayBucket[]; + casePartnersMonth?: PartnerCasesBucket[]; + efficiencyReport?: ChartBucket[]; +}; + +type TodayActions = { + upcomingAppointmentsToday?: Array<{ + id: string; + patientName: string; + startAt: string; + endAt: string; + purpose: string; + }>; +}; + +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 }; + treatmentsToday?: { count: number }; + labCasesPendingSend?: { count: number }; + casesReceivedToday?: { count: number }; + casesInProgress?: { count: number }; + tasksInProgress?: { count: number }; + importantTasks?: { count: number }; + pendingConnections?: { count: number }; + pendingStaffInvites?: { count: number }; + providersWithoutWorkingHours?: { count: number }; +}; + +@Injectable() +export class TodayService { + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + private readonly staffWorkingHoursService: StaffWorkingHoursService, + ) {} + + 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, + localeInput?: string | null, + ) { + 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 locale = normalizeCatalogLocale(localeInput); + + const widgets: TodayWidgets = {}; + const charts: TodayCharts = {}; + const actions: TodayActions = {}; + const tasks: Promise[] = []; + let subscription: TodaySubscriptionWidget | undefined; + + if (orgType === 'CLINIC') { + if (this.canViewAppointments(membership.isOwner, permissionNames)) { + tasks.push( + this.loadAppointmentsToday(organizationId, from, to, widgets), + ); + tasks.push( + this.loadAppointmentsByProvider(organizationId, from, to, charts), + ); + tasks.push( + this.loadAppointmentsWeekAll( + organizationId, + to, + query.utcOffsetMinutes, + charts, + ), + ); + } + + if (this.canEditTreatment(membership.isOwner, permissionNames)) { + tasks.push( + this.loadCasePartnersMonth( + 'CLINIC', + organizationId, + userId, + !membership.isOwner, + to, + charts, + ), + ); + tasks.push( + this.loadTreatmentPlanCompletion( + organizationId, + userId, + !membership.isOwner, + charts, + ), + ); + } + + if (this.canViewTreatment(membership.isOwner, permissionNames)) { + tasks.push( + this.loadTreatmentsToday(organizationId, from, to, widgets), + ); + tasks.push(this.loadLabCasesPendingSend(organizationId, widgets)); + tasks.push( + this.loadTreatmentMixWeek(organizationId, to, locale, charts), + ); + } + + if (this.canViewMyAppointmentsWeekChart(membership.isOwner, permissionNames)) { + tasks.push( + this.loadUpcomingAppointmentsToday( + organizationId, + userId, + from, + to, + actions, + ), + ); + tasks.push( + this.loadAppointmentsWeekMine( + organizationId, + userId, + to, + query.utcOffsetMinutes, + charts, + ), + ); + } + + if ( + this.canViewPatients(membership.isOwner, permissionNames) || + this.canViewAppointments(membership.isOwner, permissionNames) + ) { + tasks.push( + this.loadPatientsToday(organizationId, from, to, widgets), + ); + } + + if (this.canViewStaff(membership.isOwner, permissionNames)) { + tasks.push( + this.loadProvidersWithoutWorkingHours(organizationId, widgets), + ); + } + } + + if (orgType === 'LAB') { + const canViewLabWork = + this.canViewCases(membership.isOwner, permissionNames) || + this.canViewTasks(membership.isOwner, permissionNames); + + if (this.canViewCases(membership.isOwner, permissionNames)) { + tasks.push( + this.loadCasesReceivedToday(organizationId, from, to, widgets), + ); + tasks.push(this.loadCasesInProgress(organizationId, widgets)); + tasks.push(this.loadCaseCompletion(organizationId, charts)); + } + + if (this.canEditCases(membership.isOwner, permissionNames)) { + tasks.push( + this.loadCasePartnersMonth( + 'LAB', + organizationId, + userId, + false, + to, + charts, + ), + ); + } + + if (this.canViewTasks(membership.isOwner, permissionNames)) { + tasks.push(this.loadTasksInProgress(organizationId, widgets)); + tasks.push(this.loadImportantTasks(organizationId, widgets)); + tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts)); + } + + if (canViewLabWork) { + tasks.push( + this.loadLabTaskActivityWeek( + organizationId, + to, + query.utcOffsetMinutes, + charts, + ), + ); + } + } + + if (this.canManageOrganizations(membership.isOwner, permissionNames)) { + tasks.push( + this.loadPendingConnections(organizationId, widgets), + ); + } + + 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.loadPendingStaffInvites(organizationId, widgets)); + } + + await Promise.all(tasks); + + return { + success: true, + data: { + generatedAt: new Date().toISOString(), + orgType, + range: { from: from.toISOString(), to: to.toISOString() }, + widgets, + charts, + actions, + ...(subscription ? { subscription } : {}), + }, + }; + } + + private async loadTreatmentMixWeek( + organizationId: string, + rangeEnd: Date, + locale: CatalogLocale, + charts: TodayCharts, + ) { + const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000); + const grouped = await this.prisma.treatmentDetail.groupBy({ + by: ['treatmentType'], + where: { + treatment: { + organizationId, + treatmentAt: { gte: weekStart, lt: rangeEnd }, + }, + }, + _count: { _all: true }, + }); + + const sorted = grouped + .map((row) => ({ + code: row.treatmentType, + count: aggregateCount(row._count), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + if (sorted.length === 0) { + charts.treatmentMixWeek = []; + return; + } + + const labels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.TREATMENT_TYPE, + sorted.map((row) => row.code), + locale, + ); + + charts.treatmentMixWeek = sorted.map((row) => ({ + code: row.code, + label: labels.get(row.code) ?? row.code, + count: row.count, + })); + } + + private async loadTasksByProsthesis( + labOrganizationId: string, + locale: CatalogLocale, + charts: TodayCharts, + ) { + const grouped = await this.prisma.labCaseTask.groupBy({ + by: ['prosthesisTypeCode'], + where: { + status: LabTaskStatus.IN_PROGRESS, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + _count: { _all: true }, + }); + + const sorted = grouped + .map((row) => ({ + code: row.prosthesisTypeCode, + count: aggregateCount(row._count), + })) + .sort((a, b) => b.count - a.count); + + if (sorted.length === 0) { + charts.tasksByProsthesis = []; + return; + } + + const labels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.PROSTHESIS_TYPE, + sorted.map((row) => row.code), + locale, + ); + + charts.tasksByProsthesis = sorted.map((row) => ({ + code: row.code, + label: labels.get(row.code) ?? row.code, + count: row.count, + })); + } + + 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 loadUpcomingAppointmentsToday( + organizationId: string, + providerUserId: string, + from: Date, + to: Date, + actions: TodayActions, + ) { + const now = new Date(); + const items = await this.prisma.appointment.findMany({ + where: { + organizationId, + providerUserId, + startAt: { lt: to }, + endAt: { gt: now > from ? now : from }, + }, + include: { + patient: { select: { firstName: true, lastName: true } }, + }, + orderBy: { startAt: 'asc' }, + take: 10, + }); + + actions.upcomingAppointmentsToday = items.map((appointment) => ({ + id: appointment.id, + patientName: `${appointment.patient.firstName} ${appointment.patient.lastName}`.trim(), + startAt: appointment.startAt.toISOString(), + endAt: appointment.endAt.toISOString(), + purpose: appointment.purpose, + })); + } + + 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 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 getActiveEditAccessMembers( + organizationId: string, + 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, isOwner: true }, + }); + + return members.map((member) => ({ + userId: member.userId, + isOwner: member.isOwner, + })); + } + + private async buildEfficiencyReportRows( + members: Array<{ userId: string; isOwner: boolean }>, + countsByUser: Map, + ): Promise { + if (members.length === 0) { + return undefined; + } + + const userIds = members.map((member) => member.userId); + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }); + const nameById = new Map(users.map((user) => [user.id, user.name])); + + const rows = members + .map((member) => ({ + code: member.userId, + label: nameById.get(member.userId) ?? member.userId, + count: countsByUser.get(member.userId) ?? 0, + isOwner: member.isOwner, + })) + .filter((row) => !row.isOwner || row.count > 0) + .map(({ code, label, count }) => ({ code, label, count })) + .sort((a, b) => b.count - a.count); + + return rows.length >= 2 ? rows : undefined; + } + + private async loadClinicEfficiencyReport( + organizationId: string, + rangeEnd: Date, + charts: TodayCharts, + ) { + const members = await this.getActiveEditAccessMembers( + organizationId, + 'TAB_TREATMENT_EDIT', + ); + + 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: members.map((member) => member.userId) }, + }, + _count: { _all: true }, + }); + + const countsByUser = new Map( + members.map((member) => [member.userId, 0]), + ); + for (const row of grouped) { + countsByUser.set(row.providerUserId, aggregateCount(row._count)); + } + + const report = await this.buildEfficiencyReportRows(members, countsByUser); + if (report) { + charts.efficiencyReport = report; + } + } + + private async loadLabEfficiencyReport( + labOrganizationId: string, + rangeEnd: Date, + charts: TodayCharts, + ) { + const members = await this.getActiveEditAccessMembers( + labOrganizationId, + 'TAB_TASKS_EDIT', + ); + + 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: members.map((member) => member.userId) }, + task: { + labCase: { + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + }, + _count: { _all: true }, + }); + + const countsByUser = new Map( + members.map((member) => [member.userId, 0]), + ); + for (const row of grouped) { + if (!row.changedByUserId) continue; + countsByUser.set(row.changedByUserId, aggregateCount(row._count)); + } + + const report = await this.buildEfficiencyReportRows(members, countsByUser); + if (report) { + charts.efficiencyReport = report; + } + } + + 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 plan = organization.plan; + const periodStartAt = organization.createdAt.toISOString(); + + 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, + }; + } + + 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 async loadAppointmentsByProvider( + organizationId: string, + from: Date, + to: Date, + charts: TodayCharts, + ) { + const appointments = await this.prisma.appointment.findMany({ + where: { + organizationId, + startAt: { lt: to }, + endAt: { gt: from }, + }, + select: { providerUserId: true }, + }); + + const countsByProvider = new Map(); + for (const appointment of appointments) { + countsByProvider.set( + appointment.providerUserId, + (countsByProvider.get(appointment.providerUserId) ?? 0) + 1, + ); + } + + if (countsByProvider.size === 0) { + charts.appointmentsByProvider = []; + return; + } + + const sorted = [...countsByProvider.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + const users = await this.prisma.user.findMany({ + where: { id: { in: sorted.map(([userId]) => userId) } }, + select: { id: true, name: true }, + }); + const nameById = new Map(users.map((user) => [user.id, user.name])); + + charts.appointmentsByProvider = sorted.map(([userId, count]) => ({ + code: userId, + label: nameById.get(userId) ?? userId, + count, + })); + } + + private async loadProvidersWithoutWorkingHours( + organizationId: string, + widgets: TodayWidgets, + ) { + const members = await this.prisma.membership.findMany({ + where: { + organizationId, + isOwner: false, + isActive: true, + permissions: { + some: { + permission: { name: 'TAB_TREATMENT_EDIT' }, + }, + }, + }, + select: { id: true }, + }); + + if (members.length === 0) { + widgets.providersWithoutWorkingHours = { count: 0 }; + return; + } + + const scheduleBlocksByMembership = + await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds( + members.map((member) => member.id), + ); + + const count = members.filter((member) => { + const blocks = scheduleBlocksByMembership.get(member.id) ?? []; + return blocks.length === 0; + }).length; + + widgets.providersWithoutWorkingHours = { count }; + } + + private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) { + const cases = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: { + tasks: { select: { status: true } }, + }, + }); + + const count = cases.filter((labCase) => { + if (labCase.tasks.length === 0) return false; + const completed = labCase.tasks.filter( + (task) => task.status === LabTaskStatus.COMPLETED, + ).length; + return completed < labCase.tasks.length; + }).length; + + widgets.casesInProgress = { count }; + } + + private async loadCaseCompletion(labOrganizationId: string, charts: TodayCharts) { + const tasks = await this.prisma.labCaseTask.findMany({ + where: { + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + select: { status: true }, + }); + + charts.caseCompletion = this.buildCompletionGauge( + tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length, + tasks.length, + ); + } + + private async loadTreatmentPlanCompletion( + organizationId: string, + userId: string, + scopeToUser: boolean, + charts: TodayCharts, + ) { + const appointmentWhere = { + organizationId, + ...(scopeToUser ? { providerUserId: userId } : {}), + }; + + const [total, completed] = await Promise.all([ + this.prisma.appointment.count({ where: appointmentWhere }), + this.prisma.appointment.count({ + where: { + ...appointmentWhere, + treatment: { details: { some: {} } }, + }, + }), + ]); + + charts.treatmentPlanCompletion = this.buildCompletionGauge(completed, total); + } + + private buildCompletionGauge(completed: number, total: number): CompletionGaugeChart { + return { + completed, + total, + percent: total > 0 ? Math.round((completed / total) * 100) : 0, + }; + } + + private async loadAppointmentsWeekAll( + organizationId: string, + rangeEnd: Date, + utcOffsetMinutes: number | undefined, + charts: TodayCharts, + ) { + charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries( + organizationId, + rangeEnd, + utcOffsetMinutes, + ); + } + + private async loadAppointmentsWeekMine( + organizationId: string, + providerUserId: string, + rangeEnd: Date, + utcOffsetMinutes: number | undefined, + charts: TodayCharts, + ) { + charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries( + organizationId, + rangeEnd, + utcOffsetMinutes, + providerUserId, + ); + } + + private async loadAppointmentsWeekSeries( + organizationId: string, + rangeEnd: Date, + utcOffsetMinutes: number | undefined, + providerUserId?: string, + ): Promise { + const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes); + const weekStart = dayBuckets[0]?.start ?? rangeEnd; + const weekEnd = rangeEnd; + + const appointments = await this.prisma.appointment.findMany({ + where: { + organizationId, + startAt: { gte: weekStart, lt: weekEnd }, + ...(providerUserId ? { providerUserId } : {}), + }, + select: { startAt: true }, + }); + + const countsByDay = new Map(); + for (const bucket of dayBuckets) { + countsByDay.set(bucket.code, 0); + } + + const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; + for (const appointment of appointments) { + const dayKey = localDayKeyFromDate(appointment.startAt, offsetMs); + if (countsByDay.has(dayKey)) { + countsByDay.set(dayKey, (countsByDay.get(dayKey) ?? 0) + 1); + } + } + + return dayBuckets.map((bucket) => ({ + code: bucket.code, + label: bucket.label, + count: countsByDay.get(bucket.code) ?? 0, + })); + } + + private async loadLabTaskActivityWeek( + labOrganizationId: string, + rangeEnd: Date, + utcOffsetMinutes: number | undefined, + charts: TodayCharts, + ) { + const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes); + const weekStart = dayBuckets[0]?.start ?? rangeEnd; + const weekEnd = rangeEnd; + const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; + + const completedCounts = new Map(); + const receivedCounts = new Map(); + for (const bucket of dayBuckets) { + completedCounts.set(bucket.code, 0); + receivedCounts.set(bucket.code, 0); + } + + const completedTasks = await this.prisma.labCaseTask.findMany({ + where: { + status: LabTaskStatus.COMPLETED, + lastStatusChangedAt: { gte: weekStart, lt: weekEnd }, + labCase: { + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + select: { lastStatusChangedAt: true }, + }); + + for (const task of completedTasks) { + if (!task.lastStatusChangedAt) continue; + const dayKey = localDayKeyFromDate(task.lastStatusChangedAt, offsetMs); + if (completedCounts.has(dayKey)) { + completedCounts.set(dayKey, (completedCounts.get(dayKey) ?? 0) + 1); + } + } + + const sends = await this.prisma.labCaseSend.findMany({ + where: { + organizationId: labOrganizationId, + sentAt: { gte: weekStart, lt: weekEnd }, + }, + include: { + labCase: { select: { tasks: { select: { id: true } } } }, + }, + }); + + for (const send of sends) { + const dayKey = localDayKeyFromDate(send.sentAt, offsetMs); + if (receivedCounts.has(dayKey)) { + receivedCounts.set( + dayKey, + (receivedCounts.get(dayKey) ?? 0) + send.labCase.tasks.length, + ); + } + } + + charts.labTaskActivityWeek = dayBuckets.map((bucket) => ({ + code: bucket.code, + label: bucket.label, + completed: completedCounts.get(bucket.code) ?? 0, + received: receivedCounts.get(bucket.code) ?? 0, + })); + } + + private async loadCasePartnersMonth( + orgType: 'CLINIC' | 'LAB', + organizationId: string, + userId: string, + scopeToUser: boolean, + rangeEnd: Date, + charts: TodayCharts, + ) { + const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000); + + const cases = await this.prisma.labCase.findMany({ + where: { + sentAt: { gte: rangeStart, lt: rangeEnd }, + ...(orgType === 'CLINIC' + ? { + destinationOrganizationId: { not: null }, + treatment: { + organizationId, + ...(scopeToUser ? { providerUserId: userId } : {}), + }, + } + : { + sends: { some: { organizationId } }, + }), + }, + select: { + destinationOrganizationId: true, + tasks: { select: { status: true } }, + treatment: { select: { organizationId: true } }, + }, + }); + + const countsByPartner = new Map(); + + for (const labCase of cases) { + const partnerId = + orgType === 'CLINIC' + ? labCase.destinationOrganizationId + : labCase.treatment.organizationId; + if (!partnerId) continue; + + const isCompleted = + labCase.tasks.length > 0 && + labCase.tasks.every((task) => task.status === LabTaskStatus.COMPLETED); + + const entry = countsByPartner.get(partnerId) ?? { completed: 0, total: 0 }; + entry.total += 1; + if (isCompleted) entry.completed += 1; + countsByPartner.set(partnerId, entry); + } + + if (countsByPartner.size === 0) { + charts.casePartnersMonth = []; + return; + } + + const sorted = [...countsByPartner.entries()] + .map(([code, counts]) => ({ + code, + completed: counts.completed, + pending: counts.total - counts.completed, + total: counts.total, + })) + .sort((a, b) => b.total - a.total) + .slice(0, 8); + + const partners = await this.prisma.organization.findMany({ + where: { id: { in: sorted.map((row) => row.code) } }, + select: { id: true, name: true }, + }); + const nameById = new Map(partners.map((org) => [org.id, org.name])); + + charts.casePartnersMonth = sorted.map((row) => ({ + code: row.code, + label: nameById.get(row.code) ?? row.code, + completed: row.completed, + pending: row.pending, + })); + } + + 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'].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 canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean { + if (isOwner) return false; + return names.includes('TAB_TREATMENT_EDIT'); + } + + 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 canEditTreatment(isOwner: boolean, names: string[]): boolean { + if (isOwner) return true; + return names.includes('TAB_TREATMENT_EDIT'); + } + + private canEditCases(isOwner: boolean, names: string[]): boolean { + if (isOwner) return true; + return names.includes('TAB_CASES_EDIT'); + } + + 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), + ); + } +} + +function aggregateCount( + count: true | { _all?: number } | undefined, +): number { + if (!count || count === true) return 0; + return count._all ?? 0; +} + +type LocalDayBucket = { code: string; label: string; start: Date; end: Date }; + +function buildLastSevenLocalDayBuckets( + rangeEnd: Date, + utcOffsetMinutes?: number, +): LocalDayBucket[] { + const offsetMs = (utcOffsetMinutes ?? 0) * 60_000; + const dayMs = 86_400_000; + const buckets: LocalDayBucket[] = []; + + for (let index = 0; index < 7; index += 1) { + const start = new Date(rangeEnd.getTime() - (7 - index) * dayMs); + const end = new Date(start.getTime() + dayMs); + const code = localDayKeyFromDate(start, offsetMs); + buckets.push({ + code, + label: code, + start, + end, + }); + } + + return buckets; +} + +function localDayKeyFromDate(date: Date, offsetMs: number): string { + const localMs = date.getTime() + offsetMs; + const local = new Date(localMs); + const year = local.getUTCFullYear(); + const month = String(local.getUTCMonth() + 1).padStart(2, '0'); + const day = String(local.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} 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 61bc6e9..01bcb4e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -197,10 +197,68 @@ "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", + "widgetCasesInProgress": "Cases In Progress", + "widgetTasksInProgress": "Tasks In Progress", + "widgetImportantTasks": "Important Tasks", + "widgetPendingConnections": "Pending Connections", + "widgetProvidersWithoutWorkingHours": "Providers Without Working Hours", + "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", + "chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days", + "chartLabTaskActivityTitle": "Lab Task Activity", + "chartLabTaskActivitySubtitle": "Last 7 days", + "chartLabTaskCompletedLegend": "Completed", + "chartLabTaskReceivedLegend": "Received", + "chartAppointmentsByProviderTitle": "Appointments by Provider", + "chartAppointmentsByProviderSubtitle": "Today", + "chartTreatmentMixTitle": "Treatment Mix", + "chartTreatmentMixSubtitle": "Last 7 days", + "chartCaseCompletionTitle": "Case Completion", + "chartCaseCompletionSubtitle": "All active cases", + "chartCaseCompletionPercent": "{percent}%", + "chartCaseCompletionTasks": "Tasks completed", + "chartTreatmentPlanCompletionTitle": "Treatment Plan Completion", + "chartTreatmentPlanCompletionSubtitle": "All appointments", + "chartTreatmentPlanCompletionRatio": "With treatment plan", + "chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis", + "chartTasksByProsthesisSubtitle": "Current workload mix", + "chartCasePartnersClinicTitle": "Cases by Lab", + "chartCasePartnersLabTitle": "Cases by Clinic", + "chartCasePartnersSubtitle": "Last 30 days", + "chartCasePartnersSentLegend": "Sent", + "chartCasePartnersOpenLegend": "In progress", + "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", + "viewAllAppointments": "View schedule", + "noUpcomingAppointments": "No upcoming appointments for the rest of today.", + "retryLoad": "Try again", + "sectionLoadError": "This section could not be displayed.", + "lastUpdated": "Updated at {time}" }, "staff": { "redirecting": "Redirecting…", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 10a0ce2..7dd8243 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -197,10 +197,68 @@ "noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.", "choosePlanLink": "انتخاب طرح", "noSubscriptionCta": "برای شروع فرآیند خرید.", - "cardTodaysAppointments": "نوبت‌های امروز", - "cardActivePatients": "بیماران فعال", - "cardNewLabCase": "پرونده جدید لابراتوار", - "cardTodayInvoices": "صورتحساب‌های امروز" + "noWidgets": "هیچ معیاری برای دسترسی فعلی شما در دسترس نیست.", + "loadError": "بارگذاری معیارهای داشبورد ناموفق بود.", + "seatsUnlimited": "طرح نامحدود", + "widgetAppointmentsToday": "نوبت‌های امروز", + "widgetPatientsToday": "بیماران امروز", + "widgetTreatmentsToday": "درمان‌های امروز", + "widgetDraftTreatments": "درمان‌های پیش‌نویس", + "widgetLabCasesPendingSend": "پرونده‌های در انتظار ارسال", + "widgetCasesReceivedToday": "پرونده‌های دریافتی امروز", + "widgetCasesInProgress": "پرونده‌های در حال انجام", + "widgetTasksInProgress": "وظایف در حال انجام", + "widgetImportantTasks": "وظایف مهم", + "widgetPendingConnections": "درخواست‌های اتصال در انتظار", + "widgetProvidersWithoutWorkingHours": "ارائه‌دهندگان بدون ساعات کاری", + "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار", + "widgetSubscription": "اشتراک", + "subscriptionSeatsLabel": "صندلی‌های استفاده‌شده", + "subscriptionSeatsRemainingLabel": "صندلی باقی‌مانده", + "subscriptionSeatsPercent": "{percent}٪", + "subscriptionSeatsUnlimitedShort": "نامحدود", + "subscriptionPeriodLabel": "دوره اشتراک", + "subscriptionPeriodRemainingLabel": "روز باقی‌مانده", + "subscriptionPeriodPercent": "{percent}٪", + "subscriptionPeriodDays": "{elapsed}/{total} روز", + "subscriptionNoPlan": "اشتراک فعال نیست", + "chartAppointmentsWeekAllTitle": "نوبت‌های این هفته", + "chartAppointmentsWeekAllSubtitle": "همه ارائه‌دهندگان — ۷ روز گذشته", + "chartAppointmentsWeekMineTitle": "نوبت‌های من این هفته", + "chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته", + "chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه", + "chartLabTaskActivitySubtitle": "۷ روز گذشته", + "chartLabTaskCompletedLegend": "تکمیل‌شده", + "chartLabTaskReceivedLegend": "دریافت‌شده", + "chartAppointmentsByProviderTitle": "نوبت‌ها بر اساس ارائه‌دهنده", + "chartAppointmentsByProviderSubtitle": "امروز", + "chartTreatmentMixTitle": "ترکیب درمان‌ها", + "chartTreatmentMixSubtitle": "۷ روز گذشته", + "chartCaseCompletionTitle": "تکمیل پرونده‌ها", + "chartCaseCompletionSubtitle": "همه پرونده‌های فعال", + "chartCaseCompletionPercent": "{percent}٪", + "chartCaseCompletionTasks": "وظایف تکمیل‌شده", + "chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان", + "chartTreatmentPlanCompletionSubtitle": "همه نوبت‌ها", + "chartTreatmentPlanCompletionRatio": "دارای طرح درمان", + "chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز", + "chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی", + "chartCasePartnersClinicTitle": "کیس‌ها بر اساس لابراتوار", + "chartCasePartnersLabTitle": "کیس‌ها بر اساس کلینیک", + "chartCasePartnersSubtitle": "۳۰ روز گذشته", + "chartCasePartnersSentLegend": "ارسال‌شده", + "chartCasePartnersOpenLegend": "در حال انجام", + "chartEfficiencyReportTitle": "گزارش کارایی", + "chartEfficiencyReportSubtitleClinic": "درمان‌های ثبت‌شده توسط کارکنان — ۳۰ روز گذشته", + "chartEfficiencyReportSubtitleLab": "وظایف تکمیل‌شده توسط کارکنان — ۳۰ روز گذشته", + "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.", + "upcomingAppointmentsTitle": "نوبت‌های پیش رو", + "upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز", + "viewAllAppointments": "مشاهده برنامه", + "noUpcomingAppointments": "نوبت پیش‌رویی برای باقی امروز وجود ندارد.", + "retryLoad": "تلاش مجدد", + "sectionLoadError": "نمایش این بخش ممکن نشد.", + "lastUpdated": "به‌روزرسانی در {time}" }, "staff": { "redirecting": "در حال انتقال...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 4f919b3..98ed3c8 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -197,10 +197,68 @@ "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", + "widgetCasesInProgress": "Cases in uitvoering", + "widgetTasksInProgress": "Taken in uitvoering", + "widgetImportantTasks": "Belangrijke taken", + "widgetPendingConnections": "Openstaande koppelingsverzoeken", + "widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden", + "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", + "chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen", + "chartLabTaskActivityTitle": "Labtaakactiviteit", + "chartLabTaskActivitySubtitle": "Afgelopen 7 dagen", + "chartLabTaskCompletedLegend": "Voltooid", + "chartLabTaskReceivedLegend": "Ontvangen", + "chartAppointmentsByProviderTitle": "Afspraken per behandelaar", + "chartAppointmentsByProviderSubtitle": "Vandaag", + "chartTreatmentMixTitle": "Behandelingsmix", + "chartTreatmentMixSubtitle": "Afgelopen 7 dagen", + "chartCaseCompletionTitle": "Casevoltooiing", + "chartCaseCompletionSubtitle": "Alle actieve cases", + "chartCaseCompletionPercent": "{percent}%", + "chartCaseCompletionTasks": "Taken voltooid", + "chartTreatmentPlanCompletionTitle": "Behandelplanvoltooiing", + "chartTreatmentPlanCompletionSubtitle": "Alle afspraken", + "chartTreatmentPlanCompletionRatio": "Met behandelplan", + "chartTasksByProsthesisTitle": "Lopende taken per prothese", + "chartTasksByProsthesisSubtitle": "Huidige werklastmix", + "chartCasePartnersClinicTitle": "Cases per lab", + "chartCasePartnersLabTitle": "Cases per kliniek", + "chartCasePartnersSubtitle": "Afgelopen 30 dagen", + "chartCasePartnersSentLegend": "Verzonden", + "chartCasePartnersOpenLegend": "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", + "viewAllAppointments": "Bekijk planning", + "noUpcomingAppointments": "Geen komende afspraken meer voor vandaag.", + "retryLoad": "Opnieuw proberen", + "sectionLoadError": "Dit onderdeel kon niet worden weergegeven.", + "lastUpdated": "Bijgewerkt om {time}" }, "staff": { "redirecting": "Bezig met doorsturen...", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01aeb15..f102b07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "recharts": "^3.9.2", "zod": "^4.3.6" }, "devDependencies": { @@ -77,6 +78,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1585,6 +1587,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1598,6 +1626,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -2128,6 +2162,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -2170,8 +2267,9 @@ "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2186,6 +2284,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -2231,6 +2335,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2756,6 +2861,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3059,6 +3165,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3126,6 +3233,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3242,6 +3350,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", @@ -3307,9 +3424,130 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -3389,6 +3627,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", @@ -3679,6 +3923,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -3708,6 +3962,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3893,6 +4148,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4132,6 +4388,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4632,6 +4894,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4674,6 +4946,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "11.2.8", "resolved": "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-11.2.8.tgz", @@ -5865,17 +6146,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -6295,6 +6565,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6304,6 +6575,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6316,6 +6588,7 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6331,8 +6604,78 @@ "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -6378,6 +6721,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", @@ -6967,6 +7316,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7008,6 +7363,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7170,6 +7526,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7325,6 +7682,37 @@ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -7465,6 +7853,7 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 647a797..7a5c0d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "recharts": "^3.9.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/scripts/build-messages.mjs b/frontend/scripts/build-messages.mjs deleted file mode 100644 index ac27577..0000000 --- a/frontend/scripts/build-messages.mjs +++ /dev/null @@ -1,621 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const messagesDir = path.join(__dirname, '..', 'messages'); - -const en = { - common: { - appName: 'DyoLink', - loading: 'Loading...', - loadingApp: 'Loading app...', - loadingWorkspace: 'Loading workspace...', - continue: 'Continue', - back: 'Back', - save: 'Save', - cancel: 'Cancel', - delete: 'Delete', - edit: 'Edit', - next: 'Next', - dismiss: 'Dismiss', - or: 'Or', - and: 'and', - close: 'Close', - redirecting: 'Redirecting…', - readOnlyAccess: 'Read-only access for this organization.', - errorGeneric: 'Something went wrong', - loadingEllipsis: 'Loading...', - search: 'Search', - action: 'Action', - status: 'Status', - name: 'Name', - email: 'Email', - date: 'Date', - organization: 'Organization', - backToApp: '← Back to app', - copied: 'Copied', - copyLink: 'Copy link', - none: 'None', - preview: 'Preview', - }, - language: { - label: 'Language', - selectLanguage: 'Select language', - en: 'English', - fa: 'Persian', - nl: 'Dutch', - }, - theme: { - switchToLight: 'Switch to light mode', - switchToDark: 'Switch to dark mode', - lightMode: 'Light mode', - darkMode: 'Dark mode', - }, - nav: { - dashboard: 'Dashboard', - staff: 'Staff', - patients: 'Patients', - appointment: 'Appointment', - treatment: 'Treatment', - billing: 'Billing', - reports: 'Reports', - clinics: 'Clinics', - labs: 'Labs', - }, - auth: { - login: 'Login', - signIn: 'Sign in', - signOut: 'Log out', - register: 'Register', - startTrial: 'Start Trial', - startFreeTrial: 'Start Free Trial', - dashboard: 'Dashboard', - signInTitle: 'Sign in to your account', - signInPrompt: 'Or {link}', - startTrialLink: 'start your free trial', - registerTitle: 'Start your 30-day free trial', - registerPrompt: 'Already have an account?', - signInLink: 'Sign in', - email: 'Email address', - password: 'Password', - confirmPassword: 'Confirm password', - fullName: 'Full name', - rememberMe: 'Remember me', - showPassword: 'Show password', - hidePassword: 'Hide password', - forgotPassword: 'Forgot your password?', - invalidCredentials: 'Invalid email or password', - loginFailed: 'Login failed', - registrationFailed: 'Registration failed. Please try again.', - startMyFreeTrial: 'Start my free trial', - trialIncludes: 'Your trial includes:', - trialTeamMembers: 'Up to 5 team members', - trialFullAccess: 'Full access to all features', - trialNoCard: '30 days free, no credit card required', - termsAgreement: 'By signing up, you agree to our {terms} and {privacy}', - termsOfService: 'Terms of Service', - privacyPolicy: 'Privacy Policy', - signedIn: 'Signed in', - switchOrganization: 'Switch organization', - subscriptions: 'Subscriptions', - account: 'Account', - emailPlaceholder: 'you@example.com', - passwordPlaceholder: '••••••••', - namePlaceholder: 'John Doe', - termsIntro: 'By signing up, you agree to our', - errorRegistrationFailed: 'Registration failed', - errorLoginFailed: 'Login failed', - errorCreateOrganization: 'Failed to create organization', - acceptInviteTitle: 'Accept invitation', - loadingInvitation: 'Loading invitation...', - invalidInvitationLink: 'Invalid invitation link', - invitationAlreadyAccepted: 'This invitation is already accepted. You can log in now.', - errorLoadInvitation: 'Could not load invitation', - organizationLabel: 'Organization:', - emailLabel: 'Email:', - nameRequired: 'Name is required', - passwordMinLength8: 'Password must be at least 8 characters', - passwordsDoNotMatch: 'Passwords do not match', - labelName: 'Name', - labelCreatePassword: 'Create password', - labelConfirmPassword: 'Confirm password', - activateAccount: 'Activate account', - invitationAcceptedRedirect: 'Invitation Accepted. Redirecting to login...', - errorAcceptInvitation: 'Could not accept invitation', - alreadyHaveAccess: 'Already have access?', - goToLogin: 'Go to login', - acceptOrganizationTitle: 'Accept organization invitation', - alreadyHaveAccount: 'Already have an account?', - invitedBy: 'Invited by:', - ownerEmail: 'Owner email', - activateOrganization: 'Activate organization', - organizationAcceptedRedirect: 'Invitation accepted. Redirecting to login...', - stepAccount: 'Account', - stepOrganization: 'Organization', - organizationName: 'Organization name', - organizationNamePlaceholder: 'Sunshine Dental Clinic', - organizationEmail: 'Organization email', - organizationEmailPlaceholder: 'contact@sunshineclinic.com', - organizationType: 'Organization type', - dentalClinic: 'Dental Clinic', - dentalLab: 'Dental Lab', - }, - landing: { - heroTitle: 'Connect Dental Clinics & Labs', - heroHighlight: 'Seamlessly', - heroSubtitle: - 'Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.', - featureClinicsTitle: 'For Clinics', - featureClinicsDescription: - 'Manage patients, appointments, and send cases to labs instantly.', - featureLabsTitle: 'For Labs', - featureLabsDescription: 'Receive cases, track progress, and communicate with clinics.', - featureTeamTitle: 'Team Management', - featureTeamDescription: 'Add up to 5 team members during trial. Scale as you grow.', - featureTrialTitle: '30-Day Trial', - featureTrialDescription: 'Full access to all features. No credit card required.', - featureRealtimeTitle: 'Real-time Updates', - featureRealtimeDescription: 'Get instant notifications on case status changes.', - featureSecurityTitle: 'Secure & Compliant', - featureSecurityDescription: 'HIPAA-compliant with enterprise-grade security.', - footerCopyright: '© 2026 DyoLink. All rights reserved.', - termsAndConditions: 'Terms & Conditions', - }, - accountMenu: { - noActiveSubscription: 'No active subscription — review Subscriptions', - trialEnded: 'Trial ended — review Subscriptions', - trialEndingSoon: 'Trial ending soon — review Subscriptions', - seatsLow: 'Seats running low — review Subscriptions', - reviewSubscriptions: 'Review Subscriptions', - }, - validation: { - emailInvalid: 'Please enter a valid email address', - passwordRequired: 'Password is required', - nameMinLength: 'Name must be at least 2 characters', - passwordMinLength: 'Password must be at least 8 characters', - passwordUppercase: 'Password must contain at least one uppercase letter', - passwordNumber: 'Password must contain at least one number', - organizationNameMinLength: 'Organization name must be at least 2 characters', - organizationEmailInvalid: 'Please enter a valid organization email', - organizationTypeRequired: 'Please select organization type', - passwordsDoNotMatch: "Passwords don't match", - }, - today: { - welcomeBack: 'Welcome back!!', - 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', - }, - staff: { - redirecting: 'Redirecting…', - title: 'Staff Management', - subtitle: 'Invite teammates, set tab access, and stay within your plan seat limit.', - inviteMember: 'Invite member', - seatsLabel: 'Seats:', - unlimitedPlan: '(unlimited plan)', - seatLimitReached: 'Plan seat limit reached for this organization.', - noActivePlan: - 'No active plan selected for this organization. Choose a subscription plan to invite members.', - invitedPending: - 'Invitation is pending until they open the link, set a password, and log in.', - invitedAccepted: 'Invitation was accepted immediately.', - inviteLinkHeading: 'Invite link', - shareLinkHint: - 'Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.', - loadingTeam: 'Loading team…', - tableName: 'Name', - tableEmail: 'Email', - tableRole: 'Role', - tableStatus: 'Status', - tableAccess: 'Access', - tableAction: 'Action', - roleOwner: 'Owner', - roleStaff: 'Staff', - statusActive: 'Active', - statusPending: 'Pending', - statusDisabled: 'Disabled', - statusExpired: 'Expired', - allFeatures: 'All features', - inviteModalTitle: 'Invite team member', - stepOf: 'Step {step} of 2', - permissionView: 'View', - permissionEdit: 'Edit', - labelEmail: 'Email', - labelDisplayName: 'Display name', - tabAccess: 'Tab access', - sendInvite: 'Send invite', - skipForNow: 'Skip for now', - enableModalTitle: 'Enable team member', - enableConfirm: 'Enable {name} ({email})?', - enableBullet1: 'They can sign in to this organization again with their existing account.', - enableBullet2: 'No new invitation is sent and no data was removed while they were disabled.', - enableBullet3: 'Enabling uses one seat on your plan.', - noSeatsAvailable: - 'No seats are available. Disable another member or upgrade your plan before enabling this person.', - enableMemberButton: 'Enable member', - disableModalTitle: 'Disable team member', - disableConfirm: 'Disable {name} ({email})?', - disableBullet1: 'They will not be able to sign in to this organization.', - disableBullet2: 'No data will be removed.', - disableBullet3: 'Disabling frees one seat on your plan so you can invite someone else.', - disableMemberButton: 'Disable member', - editModalTitle: 'Edit member', - loadingWorkingHours: 'Loading working hours…', - errorLoadStaff: 'Failed to load staff.', - errorCopyInvite: 'Could not copy invitation link.', - errorSendInvite: 'Failed to send invitation.', - errorLoadWorkingHours: 'Failed to load working hours.', - successMemberUpdated: 'Member updated.', - errorUpdateMember: 'Failed to update member.', - errorDeleteNotImplemented: 'Delete is not implemented yet.', - successMemberDisabled: '{name} was disabled. A seat is now available.', - errorDisableMember: 'Failed to disable member.', - successMemberEnabled: '{name} was enabled and can sign in again.', - errorEnableMember: 'Failed to enable member.', - successInvited: '{name} ({email}) was invited.', - copyInviteLink: 'Copy invitation link', - copyInviteLinkTitle: 'Copy invitation link (generates a new link if needed)', - enableMemberAria: 'Enable member', - enableMemberTitle: 'Enable member (uses a seat)', - disableMemberAria: 'Disable member', - disableMemberTitle: 'Disable member (frees a seat)', - editMemberAria: 'Edit member', - deleteMemberAria: 'Delete member', - deleteMemberTitle: 'Delete member (not implemented)', - features: { - featureToday: 'Today', - featureStaff: 'Staff', - featureOrganizations: 'Organizations', - featureClinics: 'Clinics', - featureLabs: 'Labs', - featurePatients: 'Patients', - featureAppointment: 'Appointment', - featureTreatment: 'Treatment', - featureBilling: 'Billing', - featureReports: 'Reports', - noTabAccess: 'No tab access', - readOnlySuffix: '(Read only)', - }, - workingHours: { - recommendedTitle: 'Working hours recommended', - recommendedBody: - 'Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.', - intro: - 'Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.', - workingDay: 'Working day', - start: 'Start', - end: 'End', - removeShift: 'Remove shift', - addShift: 'Add shift', - autoRepeatWeekly: 'Repeat these hours at the start of each week (copy forward on Monday)', - weekdayMon: 'Mon', - weekdayTue: 'Tue', - weekdayWed: 'Wed', - weekdayThu: 'Thu', - weekdayFri: 'Fri', - weekdaySat: 'Sat', - weekdaySun: 'Sun', - validationNeedsShift: '{day} needs at least one shift or should be marked off.', - validationEndAfterStart: '{day} shift end time must be after start time.', - validationOverlap: '{day} shifts cannot overlap.', - }, - }, - patients: { - title: 'Patients', - newPatient: 'New Patient', - errorLoadPatients: 'Failed to load patients.', - successPatientSaved: 'Patient {firstName} {lastName} was saved successfully.', - errorSavePatient: 'Failed to save patient.', - firstName: 'First name', - lastName: 'Last name', - phone: 'Phone', - savePatient: 'Save Patient', - dialogTitle: 'New patient', - searchPlaceholder: 'Search patients by name, phone, email', - loadingPatients: 'Loading patients...', - noResults: 'No patients found for this search.', - noContact: 'No contact', - selectPatient: 'Select a patient to view details.', - phoneLabel: 'Phone:', - emailLabel: 'Email:', - statusLabel: 'Status:', - statusActive: 'Active', - statusInactive: 'Inactive', - emptyValue: '-', - }, - appointments: { - title: 'Appointments', - subtitle: 'Search a patient, pick a date, then click a time slot under a provider to book.', - loadingSchedule: 'Loading schedule…', - infoPastViewOnly: 'Past appointments are view-only.', - infoSelectPatient: 'Select a patient before booking.', - errorOutsideHours: - "This appointment falls outside the provider's current working hours and cannot be edited.", - successUpdated: 'Appointment updated.', - successSaved: 'Appointment saved.', - errorUpdate: 'Could not update appointment.', - errorSave: 'Could not save appointment.', - confirmRemove: 'Remove this appointment?', - successRemoved: 'Appointment removed.', - errorDelete: 'Could not delete appointment.', - errorLoadSchedule: 'Failed to load schedule.', - successPatientSaved: 'Patient {firstName} {lastName} was saved.', - searchPlaceholder: 'Search existing patients', - searching: 'Searching…', - searchHint: 'Type to search patients by name, phone, or email.', - noPermissionAdd: 'You do not have permission to add patients.', - editTitle: 'Edit appointment', - newTitle: 'New appointment', - providerLabel: 'Provider:', - patientLabel: 'Patient', - startLabel: 'Start', - endLabel: 'End', - purposeLabel: 'Purpose', - errorSelectPatient: 'Select a patient first.', - errorEndAfterStart: 'End time must be after start time.', - errorPastSchedule: 'Cannot schedule in the past.', - errorPastViewOnly: 'Past appointments are view-only.', - errorMissingDetails: 'Missing appointment details.', - noProviders: - 'No providers available. Add staff with treatment edit access to see columns here.', - noWorkingHours: - 'No working hours are configured for this day. Set provider working hours in Staff management.', - noHoursSet: 'No hours set', - offToday: 'Off today', - slotOffToday: 'Provider is off today', - slotHoursNotConfigured: 'Working hours not configured', - slotOutsideHours: 'Outside working hours', - slotCannotCreate: 'You cannot create appointments', - slotBookAt: 'Book {time}', - outsideHoursBlocked: 'Outside working hours — editing blocked', - overlappingChoose: '{count} overlapping — click to choose', - overlapping: '{count} overlapping', - legend: 'Legend', - overlappingTitle: 'Overlapping appointments ({count})', - purposeConsultation: 'Consultation', - purposeFilling: 'Filling', - purposeEndo: 'Endo', - purposeVisit: 'Visit', - purposeHygiene: 'Hygiene', - }, - treatment: { - loading: 'Loading…', - noPermissionTitle: 'Treatment workspace', - noPermissionBody: 'You do not have permission to view the Treatment tab for this organization.', - title: 'Treatment', - subtitleEdit: - 'Document cases for your appointments, save drafts, and send work to linked organizations.', - subtitleReadOnly: - 'View-only access — you can review appointments and treatment history but cannot edit.', - pastDayNotice: - 'Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.', - selectedPatient: 'Selected patient', - purposeLabel: 'Purpose:', - loadingAppointments: 'Loading appointments…', - selectDayWithAppointment: 'Select a day with at least one appointment.', - confirmDiscard: 'You have unsaved changes. Discard them and continue?', - successDraftSaved: 'Treatment draft saved.', - errorChooseOrg: 'Choose at least one active organization to send this case.', - successCaseSent: 'Case sent to selected organizations.', - successFilesUploaded: '{count} file(s) uploaded successfully.', - errorLoadAppointments: 'Failed to load appointments.', - errorLoadOrgs: 'Failed to load linked organizations.', - errorLoadHistory: 'Failed to load treatment history.', - errorLoadDraft: 'Failed to load treatment draft.', - errorUpload: 'Failed to upload attachments.', - errorSaveDraft: 'Failed to save treatment draft.', - errorSendCase: 'Failed to send case.', - errorCaseMustSave: 'Case must be saved before sending.', - draftTitle: 'Draft · {patientName}', - hiddenMessage: 'Appointments are hidden.', - showAppointments: 'Show appointments', - appointmentsTitle: 'My appointments', - hideAppointments: 'Hide appointments', - emptyDay: 'No appointments assigned to you on this day.', - casesTitle: 'Treatment cases', - casesSubtitle: 'Each case has its own teeth, notes, attachments, and destinations for send.', - addCase: 'Add case', - caseLabel: 'Case {n}', - comments: 'Comments', - commentsPlaceholder: 'Write clinical notes for this case…', - treatmentType: 'Treatment type', - typeConsultation: 'consultation', - typeFilling: 'filling', - typeEndo: 'endo', - typeVisit: 'visit', - typeHygiene: 'hygiene', - attachments: 'Attachments', - attachFiles: 'Attach files for this treatment case', - chooseFiles: 'Choose files', - sendToOrgs: 'Send this case to linked organizations', - searchOrgsPlaceholder: 'Search active organizations...', - recent: 'Recent:', - noOrgMatch: 'No active organization matches your search.', - sendThisCase: 'Send this case', - saveDraft: 'Save treatment draft', - unsavedChanges: 'Unsaved changes', - draftSaved: 'Draft saved', - sendSavesFirst: 'Sending is per case and saves first automatically.', - historyTitle: 'Previous treatments', - historySubtitle: 'Completed treatments for this patient. Each case is listed separately.', - loadingHistory: 'Loading history…', - historyEmpty: 'No prior treatments for this patient.', - statusLabel: 'Status:', - historyCaseLabel: 'Case {n} · {type}', - teethLabel: 'Teeth:', - teethNone: 'None selected', - reviewDetails: 'Review details', - previewTitle: 'Treatment preview', - previewDraft: 'Preview current draft', - selectAppointment: 'Select an appointment to preview its draft.', - caseCount: '{n} case(s)', - attachmentCount: '{n} attachment(s)', - caseSummary: 'Case {n}: {type}', - teethPrefix: '· Teeth', - moreCases: '+ {n} more case(s)', - previewDialogTitle: 'Treatment preview', - previewDialogSubtitle: 'Review cases, attachments, and send destinations.', - noCases: 'No cases in this treatment.', - typeLabel: 'Type:', - commentsLabel: 'Comments:', - commentsEmpty: 'Comments: —', - attachFilesShort: 'Attach files', - sendCase: 'Send this case', - sendToLinkedOrgs: 'Send to linked organizations', - noActiveOrgs: 'No active linked organizations.', - confirmSend: 'Confirm send', - toothChartTitle: 'FDI tooth chart', - toothChartHint: 'Tap teeth to multi-select. Applies to the active case.', - selectedLabel: 'Selected:', - selectedEmpty: '—', - upperArch: 'Upper arch', - lowerArch: 'Lower arch', - toothAria: 'FDI tooth {fdi}', - toothSelectedSuffix: ', selected', - sentToAt: 'Sent to {orgName} at {datetime}', - fallbackOrgName: 'organization', - }, - organizations: { - loadingOrganization: 'Loading organization...', - subtitle: - 'Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.', - invitationHistory: 'Invitation History', - searchPlaceholder: 'Search {counterpart} by name, email, or phone...', - backToList: 'Back to list', - tableOrganization: 'Organization', - tableOwnerEmail: 'Owner email', - tableDate: 'Date', - tableStatus: 'Status', - tableAction: 'Action', - emptyConnections: 'No connections yet. Search to send a connection request or an invitation link.', - statusInvitationPending: 'Invitation pending', - statusConnectionPending: 'Connection request pending', - statusConnected: 'Connected', - statusDeclined: 'Connection request declined', - statusFound: 'Found', - statusToday: 'Today', - acceptRequest: 'Accept connection request', - declineRequest: 'Decline connection request', - removeConnection: 'Remove connection', - sendRequest: 'Send connection request', - noDirectoryResults: 'No organization found in directory search.', - hideInvitationFields: 'Hide invitation fields', - sendInvitationLink: 'Send invitation link', - counterpartNameLabel: '{counterpart} name', - ownerEmailLabel: 'Owner email', - sendInvitation: 'Send invitation', - successConnectionSent: '{counterpart} connection request sent.', - successInviteCreated: 'Invitation link created for {email}', - successLinkCopied: 'Invitation link copied to clipboard.', - successAccepted: 'Connection request accepted.', - successDeclined: 'Connection request declined.', - successRemoved: 'Connection removed.', - historyTitle: 'Invitation History', - loadingHistory: 'Loading invitation history...', - historyEmpty: 'No invitations yet.', - tableInvitationLink: 'Invitation link', - statusPending: 'Invitation pending', - statusAccepted: 'Invitation accepted', - statusRejected: 'Invitation rejected', - statusExpired: 'Invitation expired', - copyInvitationLink: 'Copy invitation link', - copyInvitationLinkTitle: 'Copy invitation link (generates a new link if needed)', - selectorTitle: 'Organizations', - selectorSubtitleWithCreate: 'Select an organization to continue, or create a new one.', - selectorSubtitleSelectOnly: 'Select an organization to continue.', - createOrganization: 'Create Organization', - createAndContinue: 'Create and Continue', - emptyCanCreate: 'No organizations found. Create your first one to continue.', - emptyAskOwner: 'No organizations found. Ask an organization owner to invite you.', - continueArrow: 'Continue →', - planLabel: 'Plan: {name} • {maxUsers} users', - }, - settings: { - accountTitle: 'Account', - accountSubtitle: 'Profile and security settings for your login.', - accountPlaceholder: - 'Password change and profile editing will be wired here next (e.g. invite flow, reset password).', - subscriptionsTitle: 'Subscriptions', - subscriptionsSubtitle: - 'Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.', - noSubscriptionNotice: - 'This organization has no active subscription. Select a plan below to start the purchase process.', - currentPlan: 'Current plan', - planPrice: 'Plan price', - seatsUsed: 'Seats used', - seatsRemaining: 'Seats remaining', - daysRemaining: 'Days remaining', - unlimited: 'Unlimited', - unlimitedSeats: 'Unlimited seats', - seatsCount: '{n} seats', - pricePerMonth: '${price} / month', - noActiveSubscription: 'No active subscription for this organization.', - trialEnded: 'Trial period has ended. Choose a plan when checkout is available.', - trialEndsIn: 'Trial ends in {days} day(s).', - seatsLow: 'Seat usage is high for this organization.', - choosePlanIntro: - 'Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.', - planSolo: 'Solo', - planSmall: 'Small', - planMedium: 'Medium', - planLarge: 'Large', - planEnterprise: 'Enterprise', - startPurchase: 'Start purchase process', - purchaseNotice: - 'Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup.', - }, - schedule: { - defaultLabel: 'Schedule date', - previousDay: 'Previous day', - nextDay: 'Next day', - chooseDate: 'Choose schedule date', - year: 'Year', - month: 'Month', - day: 'Day', - monthJanuary: 'January', - monthFebruary: 'February', - monthMarch: 'March', - monthApril: 'April', - monthMay: 'May', - monthJune: 'June', - monthJuly: 'July', - monthAugust: 'August', - monthSeptember: 'September', - monthOctober: 'October', - monthNovember: 'November', - monthDecember: 'December', - }, -}; - -function deepMerge(base, overlay) { - const result = { ...base }; - for (const key of Object.keys(base)) { - const baseVal = base[key]; - const overlayVal = overlay?.[key]; - if (baseVal && typeof baseVal === 'object' && !Array.isArray(baseVal)) { - result[key] = deepMerge(baseVal, overlayVal ?? {}); - } else if (overlayVal !== undefined) { - result[key] = overlayVal; - } - } - return result; -} - -function writeJson(file, data) { - fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); -} - -writeJson(path.join(messagesDir, 'en.json'), en); - -for (const locale of ['fa', 'nl']) { - const file = path.join(messagesDir, `${locale}.json`); - const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; - writeJson(file, deepMerge(en, existing)); -} - -console.log('Messages built: en.json updated; fa.json and nl.json merged with existing translations.'); diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 417b101..df110b4 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 efd4cc9..b51a2a2 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,24 +1,47 @@ 'use client'; +import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Card } from '@/components/ui/shared/Card'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { TodayDashboard } from '@/components/today/TodayDashboard'; +import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; +import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; +import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary'; +import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; export default function TodayPage() { const t = useTranslations('today'); const { currentOrganization } = useAuth(); - const showNoSubscriptionNotice = - Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; + const orgId = currentOrganization?.id; + const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); + + const showNoSubscriptionNotice = useMemo( + () => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan, + [currentOrganization], + ); + + const sectionErrorMessage = t('sectionLoadError'); return ( -
-

- {t('welcomeBack')} -

+
+
+

{t('welcomeBack')}

+ {data?.generatedAt && !isInitialLoad ? ( +

+ {t('lastUpdated', { + time: new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }).format(new Date(data.generatedAt)), + })} +

+ ) : null} +
{showNoSubscriptionNotice && ( -
+

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

)} -
- -

{t('cardTodaysAppointments')}

-

12

-

Monday 2/5/2026

-
- -

{t('cardActivePatients')}

-

675

-
- -

{t('cardNewLabCase')}

-

5

-

35 ↑

-
- -

{t('cardTodayInvoices')}

-

1200$

-

21,300 $

-
-
+ {error ? ( + void reload()} + isRetrying={loading && Boolean(data)} + /> + ) : null} + + } + > + +
); } diff --git a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx index 6b45904..a9c4d5d 100644 --- a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx @@ -1,12 +1,15 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace'; import { useAuth } from '@/lib/hooks/useAuth'; export default function TreatmentPage() { const t = useTranslations('treatment'); const { user, currentOrganization, isAuthReady } = useAuth(); + const searchParams = useSearchParams(); + const initialAppointmentId = searchParams.get('appointmentId'); if (!isAuthReady || !user) { return ( @@ -15,6 +18,10 @@ export default function TreatmentPage() { } return ( - + ); } diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index aa0f649..c90b75c 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 */ @@ -164,6 +146,14 @@ export function canEditTreatment(org: Organization | null): boolean { return hasPermission(org, 'TAB_TREATMENT_EDIT'); } +/** Staff treatment editors only — personal schedule Today gadgets (not owners). */ +export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'CLINIC') return false; + if (org.isOwner) return false; + return hasPermission(org, 'TAB_TREATMENT_EDIT'); +} + /** View treatment workspace (read-only or edit) */ export function canViewTreatment(org: Organization | null): boolean { if (!org) return false; @@ -210,3 +200,18 @@ export function canEditTasks(org: Organization | null): boolean { if (org.isOwner) return true; return hasPermission(org, 'TAB_TASKS_EDIT'); } + +/** Appointments tab only (excludes treatment-only access). */ +export function canViewAppointmentsTab(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') + ); +} + +export function canViewLabCasesOrTasks(org: Organization | null): boolean { + return canViewCases(org) || canViewTasks(org); +} diff --git a/frontend/src/components/shared/treatmentSelection.ts b/frontend/src/components/shared/treatmentSelection.ts index e3ac585..432e17a 100644 --- a/frontend/src/components/shared/treatmentSelection.ts +++ b/frontend/src/components/shared/treatmentSelection.ts @@ -2,8 +2,9 @@ import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTim import type { TreatmentAppointment } from '@/types/treatment'; /** - * For the selected calendar day: if it is today, pick the appointment whose time range contains now; - * otherwise pick the first appointment of that day. Returns null when there are no appointments. + * For the selected calendar day: if it is today, pick the in-progress appointment, + * otherwise the appointment whose start time is nearest to now; on other days pick + * the first appointment of that day. Returns null when there are no appointments. */ export function pickAutoAppointment( appointments: TreatmentAppointment[], @@ -19,7 +20,23 @@ export function pickAutoAppointment( const e = new Date(a.endAt).getTime(); if (t >= s && t <= e) return a.id; } + + let nearest = appointments[0]; + let nearestDistance = Math.abs(new Date(nearest.startAt).getTime() - t); + for (const appointment of appointments.slice(1)) { + const distance = Math.abs(new Date(appointment.startAt).getTime() - t); + if (distance < nearestDistance) { + nearest = appointment; + nearestDistance = distance; + } + } + return nearest.id; } return appointments[0].id; } + +export function treatmentAppointmentHref(appointmentId?: string): string { + if (!appointmentId) return '/treatment'; + return `/treatment?appointmentId=${encodeURIComponent(appointmentId)}`; +} diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/today/ChartCard.tsx new file mode 100644 index 0000000..aa7d6e1 --- /dev/null +++ b/frontend/src/components/today/ChartCard.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react'; +import { Card } from '@/components/ui/shared/Card'; +import { ChartCardSkeleton } from '@/components/today/TodaySkeleton'; + +interface ChartCardProps { + title: string; + subtitle?: string; + children: ReactNode; + emptyMessage?: string; + isEmpty?: boolean; + loading?: boolean; + /** + * Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel). + * Chart column is independent and vertically centered. + */ + sidePanelLayout?: boolean; + chartPanel?: ReactNode; +} + +function ChartCardHeader({ + title, + subtitle, +}: Pick) { + return ( +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ ); +} + +export function ChartCard({ + title, + subtitle, + children, + emptyMessage, + isEmpty = false, + loading = false, + sidePanelLayout = false, + chartPanel, +}: ChartCardProps) { + if (loading) { + return ; + } + + if (sidePanelLayout) { + return ( + +
+ + {isEmpty ? ( +
+
+

{emptyMessage}

+
+
+ ) : ( +
{children}
+ )} +
+ + {!isEmpty && chartPanel ? ( +
+
+ {chartPanel} +
+
+ ) : null} +
+ ); + } + + return ( + + + + {isEmpty ? ( +
+

{emptyMessage}

+
+ ) : ( +
{children}
+ )} +
+ ); +} diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/today/KpiCard.tsx new file mode 100644 index 0000000..983e15a --- /dev/null +++ b/frontend/src/components/today/KpiCard.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Link } from '@/i18n/navigation'; +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; + href?: string; + className?: string; +} + +export function KpiCard({ + title, + value, + subtitle, + icon: Icon, + color = 'default', + loading = false, + href, + className = '', +}: KpiCardProps) { + const card = ( + +
+

{title}

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

{value}

+ )} + {subtitle ? ( +

{subtitle}

+ ) : null} + + ); + + if (href && !loading) { + return ( + + {card} + + ); + } + + return card; +} diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/today/TodayAreaChart.tsx new file mode 100644 index 0000000..f3d7822 --- /dev/null +++ b/frontend/src/components/today/TodayAreaChart.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import type { TodayChartBucket } from '@/types/today'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_PRIMARY_COLOR, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; + +interface TodayAreaChartProps { + data: TodayChartBucket[]; + color?: string; + gradientId?: string; + showXAxis?: boolean; +} + +export function TodayAreaChart({ + data, + color = TODAY_CHART_PRIMARY_COLOR, + gradientId = 'todayAreaFill', + showXAxis = true, +}: TodayAreaChartProps) { + return ( + + + + + + + + + + + {showXAxis ? ( + + ) : ( + + )} + + String(label)} + /> + + + + + ); +} diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/today/TodayBarChart.tsx new file mode 100644 index 0000000..db701e6 --- /dev/null +++ b/frontend/src/components/today/TodayBarChart.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { TodayChartBucket } from '@/types/today'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COLORS, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_TOOLTIP_BG, + TODAY_CHART_TOOLTIP_BORDER, +} from '@/components/today/chart-theme'; + +interface TodayBarChartProps { + data: TodayChartBucket[]; + colorForCode?: (code: string, index: number) => string; +} + +export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + shortLabel: truncateLabel(item.label), + })); + + return ( + + + + + { + const { x, y, payload } = props as { + x: number; + y: number; + payload: { value: string }; + }; + const index = chartData.findIndex((row) => row.shortLabel === payload.value); + const entry = chartData[index]; + const fill = + entry != null + ? colorForCode(entry.code, index >= 0 ? index : 0) + : TODAY_CHART_AXIS_COLOR; + return ( + + {payload.value} + + ); + } + : { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 } + } + axisLine={{ stroke: TODAY_CHART_GRID_COLOR }} + tickLine={false} + interval={0} + /> + + { + const row = payload?.[0]?.payload as TodayChartBucket | undefined; + return row?.label ?? ''; + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + + ); +} + +function truncateLabel(label: string, max = 12): string { + if (label.length <= max) return label; + return `${label.slice(0, max - 1)}…`; +} 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/TodayCompletionGaugeKpiCard.tsx b/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx new file mode 100644 index 0000000..243f696 --- /dev/null +++ b/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx @@ -0,0 +1,64 @@ +'use client'; + +import type { LucideIcon } 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'; +import type { TodayCompletionGauge } from '@/types/today'; + +export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge { + title: string; + subtitle: string; + percentLabel: string; + ratioLabel: string; + href: string; + icon: LucideIcon; +} + +export function TodayCompletionGaugeKpiCard({ + completed, + total, + percent, + title, + subtitle, + percentLabel, + ratioLabel, + href, + icon: Icon, +}: TodayCompletionGaugeKpiCardProps) { + return ( + + +
+
+

{title}

+

{subtitle}

+
+ +
+ +
+
+ 0 ? percent : 0} + completed={completed} + total={total} + percentLabel={total > 0 ? percentLabel : '—'} + tasksLabel={ratioLabel} + fillColor={TODAY_CHART_COMPLETED_COLOR} + showRatio={total > 0} + /> +
+
+
+ + ); +} diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/today/TodayDashboard.tsx new file mode 100644 index 0000000..52423b8 --- /dev/null +++ b/frontend/src/components/today/TodayDashboard.tsx @@ -0,0 +1,686 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { + canEditCases, + canEditTreatment, + canViewAppointmentsTab, + canViewCases, + canViewLabCasesOrTasks, + canViewMyAppointmentsWeekChart, + 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 { + mapWeekChartBuckets, + useTodayDayLabelFormatter, +} from '@/components/today/chart-day-labels'; +import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; +import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart'; +import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; +import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart'; +import { Package, Stethoscope, type LucideIcon } from 'lucide-react'; +import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard'; +import { + mapLabTaskActivityChartData, + TodayLabTaskActivityChart, +} from '@/components/today/TodayLabTaskActivityChart'; +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 } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import type { + TodayCompletionGauge, + 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 && + canViewMyAppointmentsWeekChart(currentOrganization); + + const showCasePartnersChart = Boolean( + currentOrganization && + ((orgType === 'CLINIC' && canEditTreatment(currentOrganization)) || + (orgType === 'LAB' && canEditCases(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 showTreatmentPlanCompletionCard = + orgType === 'CLINIC' && + Boolean(currentOrganization && canEditTreatment(currentOrganization)) && + (isInitialLoad || charts.treatmentPlanCompletion !== undefined); + + const cells = useMemo(() => { + if (isInitialLoad) { + return buildSkeletonCells({ + kpiDefinitions, + showSubscriptionCard, + showCaseCompletionCard, + showTreatmentPlanCompletionCard, + showUpcoming: Boolean(showUpcoming), + showCharts, + orgType, + isOwner, + showMyAppointmentsWeekChart: Boolean( + currentOrganization && + canViewMyAppointmentsWeekChart(currentOrganization), + ), + showCasePartnersChart, + charts, + }); + } + + return buildDashboardCells({ + t, + dayLabelFormatter, + widgets, + charts, + actions, + subscription, + kpiDefinitions, + showSubscriptionCard: showSubscriptionCard && Boolean(subscription), + showCaseCompletionCard: + showCaseCompletionCard && charts.caseCompletion !== undefined, + showTreatmentPlanCompletionCard: + showTreatmentPlanCompletionCard && + charts.treatmentPlanCompletion !== undefined, + showUpcoming: Boolean(showUpcoming), + showCharts, + orgType, + isOwner, + currentOrganization, + }); + }, [ + isInitialLoad, + kpiDefinitions, + showSubscriptionCard, + showCaseCompletionCard, + showTreatmentPlanCompletionCard, + 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; + showTreatmentPlanCompletionCard: boolean; + showUpcoming: boolean; + showCharts: boolean; + orgType?: 'CLINIC' | 'LAB'; + isOwner: boolean; + showMyAppointmentsWeekChart: boolean; + showCasePartnersChart: boolean; + charts: TodaySummaryCharts; +}): TodayDashboardCell[] { + const cells: TodayDashboardCell[] = []; + + if (options.showCharts) { + const chartCount = countVisibleCharts( + options.charts, + options.orgType, + options.isOwner, + options.showMyAppointmentsWeekChart, + options.showCasePartnersChart, + ); + 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: , + }); + } + + if (options.showTreatmentPlanCompletionCard) { + cells.push({ + id: 'treatment-plan-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; + showTreatmentPlanCompletionCard: boolean; + showUpcoming: boolean; + showCharts: boolean; + orgType?: 'CLINIC' | 'LAB'; + isOwner: boolean; + currentOrganization: ReturnType['currentOrganization']; +}): TodayDashboardCell[] { + const cells: TodayDashboardCell[] = []; + + if (options.showCharts) { + cells.push( + ...buildChartCells({ + t: options.t, + charts: options.charts, + orgType: options.orgType, + isOwner: options.isOwner, + showMyAppointmentsWeekChart: Boolean( + options.currentOrganization && + canViewMyAppointmentsWeekChart(options.currentOrganization), + ), + showCasePartnersChart: + Boolean(options.currentOrganization) && + ((options.orgType === 'CLINIC' && + canEditTreatment(options.currentOrganization)) || + (options.orgType === 'LAB' && + canEditCases(options.currentOrganization))), + 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) { + pushCompletionGaugeCell(cells, { + id: 'case-completion', + gauge: options.charts.caseCompletion, + title: options.t('chartCaseCompletionTitle'), + subtitle: options.t('chartCaseCompletionSubtitle'), + percentLabel: options.t('chartCaseCompletionPercent', { + percent: options.charts.caseCompletion.percent, + }), + ratioLabel: options.t('chartCaseCompletionTasks'), + href: '/cases', + icon: Package, + }); + } + + if ( + options.showTreatmentPlanCompletionCard && + options.charts.treatmentPlanCompletion !== undefined + ) { + pushCompletionGaugeCell(cells, { + id: 'treatment-plan-completion', + gauge: options.charts.treatmentPlanCompletion, + title: options.t('chartTreatmentPlanCompletionTitle'), + subtitle: options.t('chartTreatmentPlanCompletionSubtitle'), + percentLabel: options.t('chartCaseCompletionPercent', { + percent: options.charts.treatmentPlanCompletion.percent, + }), + ratioLabel: options.t('chartTreatmentPlanCompletionRatio'), + href: '/appointments', + icon: Stethoscope, + }); + } + + 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; + showMyAppointmentsWeekChart: boolean; + showCasePartnersChart: boolean; + dayLabelFormatter: ReturnType; +}): TodayDashboardCell[] { + const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options; + const cells: TodayDashboardCell[] = []; + const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea; + const barChart = TODAY_DASHBOARD_LAYOUT.chartBar; + + const appointmentsWeekAllData = mapWeekChartBuckets( + charts.appointmentsWeekAll ?? [], + options.dayLabelFormatter, + ); + const appointmentsWeekMineData = mapWeekChartBuckets( + charts.appointmentsWeekMine ?? [], + options.dayLabelFormatter, + ); + const labTaskActivityData = mapWeekChartBuckets( + charts.labTaskActivityWeek ?? [], + options.dayLabelFormatter, + ); + const labTaskActivityChartData = mapLabTaskActivityChartData(labTaskActivityData); + + if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) { + cells.push({ + id: 'chart-appointments-week-all', + layout: areaChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + if ( + orgType === 'CLINIC' && + showMyAppointmentsWeekChart && + charts.appointmentsWeekMine !== undefined + ) { + cells.push({ + id: 'chart-appointments-week-mine', + layout: areaChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) { + cells.push({ + id: 'chart-lab-task-activity', + layout: areaChart, + content: ( + row.completed === 0 && row.received === 0, + )} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + const efficiencyReportData = charts.efficiencyReport ?? []; + if ( + isOwner && + charts.efficiencyReport !== undefined && + efficiencyReportData.length >= 2 + ) { + cells.push({ + id: 'chart-efficiency-report', + layout: areaChart, + content: ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + sidePanelLayout + chartPanel={ + + efficiencyReportData.find((row) => row.code === code)?.label ?? code + } + variant="pie" + /> + } + > + + efficiencyReportData.find((row) => row.code === code)?.label ?? code + } + /> + + ), + }); + } + + const appointmentsByProviderData = charts.appointmentsByProvider ?? []; + if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) { + cells.push({ + id: 'chart-appointments-by-provider', + layout: barChart, + content: ( + + + + ), + }); + } + + const treatmentData = charts.treatmentMixWeek ?? []; + if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) { + cells.push({ + id: 'chart-treatment-mix', + layout: barChart, + content: ( + + treatmentTypeColor(code, index)} + /> + + ), + }); + } + + const tasksByProsthesisData = charts.tasksByProsthesis ?? []; + if (orgType === 'LAB' && charts.tasksByProsthesis !== undefined) { + cells.push({ + id: 'chart-tasks-by-prosthesis', + layout: barChart, + content: ( + + prosthesisTypeColor(code, index)} + /> + + ), + }); + } + + const casePartnersData = charts.casePartnersMonth ?? []; + if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) { + cells.push({ + id: 'chart-case-partners-month', + layout: barChart, + content: ( + row.completed === 0 && row.pending === 0, + )} + emptyMessage={t('chartEmpty')} + > + + + ), + }); + } + + return cells; +} + +function countVisibleCharts( + charts: TodaySummaryCharts, + orgType?: 'CLINIC' | 'LAB', + isOwner = false, + showMyAppointmentsWeekChart = false, + showCasePartnersChart = false, +): number { + let count = 0; + if (orgType === 'CLINIC') { + count += charts.appointmentsWeekAll !== undefined ? 1 : 0; + count += + showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0; + count += charts.appointmentsByProvider !== undefined ? 1 : 0; + count += charts.treatmentMixWeek !== undefined ? 1 : 0; + count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0; + } + if (orgType === 'LAB') { + count += charts.labTaskActivityWeek !== undefined ? 1 : 0; + count += charts.tasksByProsthesis !== undefined ? 1 : 0; + count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0; + } + if ( + isOwner && + charts.efficiencyReport !== undefined && + (charts.efficiencyReport?.length ?? 0) >= 2 + ) { + count += 1; + } + return count; +} + +function pushCompletionGaugeCell( + cells: TodayDashboardCell[], + options: { + id: string; + gauge: TodayCompletionGauge; + title: string; + subtitle: string; + percentLabel: string; + ratioLabel: string; + href: string; + icon: LucideIcon; + }, +) { + cells.push({ + id: options.id, + layout: TODAY_DASHBOARD_LAYOUT.subscription, + content: ( + + ), + }); +} 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 new file mode 100644 index 0000000..302cbf3 --- /dev/null +++ b/frontend/src/components/today/TodayDonutChart.tsx @@ -0,0 +1,168 @@ +'use client'; + +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 { + chartRankColor, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; + +interface TodayDonutChartBaseProps { + data: TodayChartBucket[]; + labelForCode: (code: string) => string; + colorForCode?: (code: string, index: number) => string; + swatchStyleForCode?: (code: string, index: number) => CSSProperties; +} + +interface TodayDonutChartProps extends TodayDonutChartBaseProps { + variant?: 'donut' | 'pie'; + /** Inline legend + chart row (legacy). Prefer TodayDonutChartLegend + sidePanelLayout. */ + sideLegend?: boolean; +} + +function useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, +}: TodayDonutChartBaseProps) { + const chartData = data.map((item) => ({ + ...item, + displayLabel: labelForCode(item.code), + })); + + const resolveColor = (code: string, index: number) => + colorForCode?.(code, index) ?? chartRankColor(index); + + const resolveSwatchStyle = (code: string, index: number): CSSProperties => + swatchStyleForCode?.(code, index) ?? { + backgroundColor: resolveColor(code, index), + borderColor: 'rgba(0, 0, 0, 0.18)', + }; + + return { chartData, resolveColor, resolveSwatchStyle }; +} + +export function TodayDonutChartLegend({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, +}: TodayDonutChartBaseProps) { + const { chartData, resolveSwatchStyle } = useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + }); + + const rowClass = 'flex h-4 items-center text-xs leading-none'; + + return ( +
+
+ {chartData.map((entry, index) => ( + + + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.displayLabel} + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.count} + + ))} +
+
+ ); +} + +export function TodayDonutChart({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + variant = 'donut', + sideLegend = false, +}: TodayDonutChartProps) { + const { chartData, resolveColor } = useDonutChartModel({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + }); + + const innerRadius = variant === 'pie' ? 0 : '62%'; + const outerRadius = variant === 'pie' ? '88%' : 92; + + const pieChart = ( + + + + {chartData.map((entry, index) => ( + + ))} + + { + const row = item?.payload as TodayChartBucket | undefined; + return [value, row ? labelForCode(row.code) : '']; + }} + /> + + + ); + + if (sideLegend) { + return ( +
+
+ +
+
+ {pieChart} +
+
+ ); + } + + return {pieChart}; +} diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/today/TodayHorizontalBarChart.tsx new file mode 100644 index 0000000..8e70812 --- /dev/null +++ b/frontend/src/components/today/TodayHorizontalBarChart.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import type { TodayChartBucket } from '@/types/today'; +import { + chartRankColor, + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; + +interface TodayHorizontalBarChartProps { + data: TodayChartBucket[]; +} + +export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + shortLabel: truncateLabel(item.label, 18), + })); + + return ( + + + + + + + { + const row = payload?.[0]?.payload as TodayChartBucket | undefined; + return row?.label ?? ''; + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + + ); +} + +function truncateLabel(label: string, max = 18): string { + if (label.length <= max) return label; + return `${label.slice(0, max - 1)}…`; +} diff --git a/frontend/src/components/today/TodayLabTaskActivityChart.tsx b/frontend/src/components/today/TodayLabTaskActivityChart.tsx new file mode 100644 index 0000000..b76f9d7 --- /dev/null +++ b/frontend/src/components/today/TodayLabTaskActivityChart.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COMPLETED_COLOR, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_RECEIVED_COLOR, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; +import type { TodayStackedDayBucket } from '@/types/today'; + +export type LabTaskActivityChartRow = { + label: string; + completed: number; + received: number; +}; + +interface TodayLabTaskActivityChartProps { + data: LabTaskActivityChartRow[]; + completedLabel: string; + receivedLabel: string; +} + +export function TodayLabTaskActivityChart({ + data, + completedLabel, + receivedLabel, +}: TodayLabTaskActivityChartProps) { + return ( + +
+
+ + + + + + + + + + + + + + + + String(label)} + /> + + + + +
+ +
+ + + {completedLabel} + + + + {receivedLabel} + +
+
+
+ ); +} + +export function mapLabTaskActivityChartData( + buckets: TodayStackedDayBucket[], +): LabTaskActivityChartRow[] { + return buckets.map((bucket) => ({ + label: bucket.label, + completed: bucket.completed, + received: bucket.received, + })); +} diff --git a/frontend/src/components/today/TodayLoadErrorBanner.tsx b/frontend/src/components/today/TodayLoadErrorBanner.tsx new file mode 100644 index 0000000..d5f7090 --- /dev/null +++ b/frontend/src/components/today/TodayLoadErrorBanner.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Button } from '@/components/ui/shared/Button'; + +interface TodayLoadErrorBannerProps { + message: string; + retryLabel: string; + onRetry: () => void; + isRetrying?: boolean; +} + +export function TodayLoadErrorBanner({ + message, + retryLabel, + onRetry, + isRetrying = false, +}: TodayLoadErrorBannerProps) { + return ( +
+

{message}

+ +
+ ); +} diff --git a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx b/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx new file mode 100644 index 0000000..230e010 --- /dev/null +++ b/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COMPLETED_COLOR, + TODAY_CHART_GRID_COLOR, + TODAY_CHART_RECEIVED_COLOR, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; +import type { TodayPartnerCasesBucket } from '@/types/today'; + +interface TodayPartnerCasesStackedBarChartProps { + data: TodayPartnerCasesBucket[]; + completedLabel: string; + pendingLabel: string; +} + +export function TodayPartnerCasesStackedBarChart({ + data, + completedLabel, + pendingLabel, +}: TodayPartnerCasesStackedBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + shortLabel: truncateLabel(item.label), + })); + + return ( + +
+
+ + + + + + { + const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined; + return row?.label ?? ''; + }} + /> + + + + +
+ +
+ + + {completedLabel} + + + + {pendingLabel} + +
+
+
+ ); +} + +function truncateLabel(label: string, max = 12): string { + if (label.length <= max) return label; + return `${label.slice(0, max - 1)}…`; +} diff --git a/frontend/src/components/today/TodayRadialGaugeChart.tsx b/frontend/src/components/today/TodayRadialGaugeChart.tsx new file mode 100644 index 0000000..c3b8dbf --- /dev/null +++ b/frontend/src/components/today/TodayRadialGaugeChart.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { + PolarAngleAxis, + RadialBar, + RadialBarChart, + ResponsiveContainer, +} from 'recharts'; + +import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme'; + +interface TodayRadialGaugeChartProps { + percent: number; + completed: number; + total: number; + percentLabel: string; + tasksLabel: string; + size?: 'sm' | 'md'; + fillColor?: string; + showRatio?: boolean; + /** Override ring hole size (e.g. "72%" leaves more room for center labels). */ + innerRadius?: string | number; + /** Override compact chart wrapper height class when size is "sm". */ + compactClassName?: string; + /** Ring thickness when size is "sm". */ + compactBarSize?: number; +} + +export function TodayRadialGaugeChart({ + percent, + completed, + total, + percentLabel, + tasksLabel, + size = 'md', + fillColor = TODAY_CHART_PRIMARY_COLOR, + showRatio = true, + innerRadius, + compactClassName, + compactBarSize, +}: TodayRadialGaugeChartProps) { + const isCompact = size === 'sm'; + const clamped = Math.max(0, Math.min(100, percent)); + const data = [{ name: 'progress', value: clamped, fill: fillColor }]; + const resolvedInnerRadius = innerRadius ?? (isCompact ? '62%' : '68%'); + const resolvedBarSize = isCompact ? (compactBarSize ?? 9) : 14; + const wrapperClass = isCompact + ? compactClassName ?? 'h-[108px]' + : 'h-full min-h-0 flex-1'; + + return ( +
+ + + + + + +
+ + {percentLabel} + + + {tasksLabel} + + {showRatio && total > 0 ? ( + + {completed}/{total} + + ) : null} +
+
+ ); +} diff --git a/frontend/src/components/today/TodaySectionErrorFallback.tsx b/frontend/src/components/today/TodaySectionErrorFallback.tsx new file mode 100644 index 0000000..5520e3e --- /dev/null +++ b/frontend/src/components/today/TodaySectionErrorFallback.tsx @@ -0,0 +1,13 @@ +import { Card } from '@/components/ui/shared/Card'; + +interface TodaySectionErrorFallbackProps { + message: string; +} + +export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) { + return ( + +

{message}

+
+ ); +} diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/today/TodaySkeleton.tsx new file mode 100644 index 0000000..c49a6f8 --- /dev/null +++ b/frontend/src/components/today/TodaySkeleton.tsx @@ -0,0 +1,38 @@ +interface SkeletonBlockProps { + className?: string; +} + +export function SkeletonBlock({ className = '' }: SkeletonBlockProps) { + return ( +
+ ); +} + +export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) { + return ( +
+ + + {!tall ? : null} +
+ ); +} + +export function ChartCardSkeleton() { + return ( +
+ + + +
+ ); +} + +export function ListRowSkeleton({ compact = false }: { compact?: boolean }) { + return ; +} 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 new file mode 100644 index 0000000..0f92981 --- /dev/null +++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { ChevronRight } from 'lucide-react'; +import { Link } from '@/i18n/navigation'; +import { Card } from '@/components/ui/shared/Card'; +import { formatTimeForInput } from '@/components/appointments/appointmentTime'; +import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection'; +import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import { ListRowSkeleton } from '@/components/today/TodaySkeleton'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { TodaySummaryActions } from '@/types/today'; + +interface TodayUpcomingAppointmentsProps { + actions: TodaySummaryActions; + loading?: boolean; + isInitialLoad?: boolean; +} + +export function TodayUpcomingAppointments({ + actions, + loading = false, + isInitialLoad = false, +}: TodayUpcomingAppointmentsProps) { + const t = useTranslations('today'); + const { currentOrganization } = useAuth(); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + useEffect(() => { + void treatmentCatalogApi + .list() + .then((response) => setTreatmentCatalog(response.data)) + .catch(() => {}); + }, []); + + if ( + !currentOrganization || + currentOrganization.type !== 'CLINIC' || + !canViewMyAppointmentsWeekChart(currentOrganization) + ) { + return null; + } + + const appointments = actions.upcomingAppointmentsToday ?? []; + + if (isInitialLoad) { + return ( + +
+
+
+
+
+ {[0, 1].map((key) => ( + + ))} +
+ + ); + } + + return ( + +
+
+

+ {t('upcomingAppointmentsTitle')} +

+

{t('upcomingAppointmentsSubtitle')}

+
+ + {t('viewAllAppointments')} + +
+ + {appointments.length === 0 ? ( +
+

{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); + + return ( +
  • + +
    +

    + {appointment.patientName} +

    +

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

    +
    + + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/today/TodayWidgetErrorBoundary.tsx b/frontend/src/components/today/TodayWidgetErrorBoundary.tsx new file mode 100644 index 0000000..5bc0ec0 --- /dev/null +++ b/frontend/src/components/today/TodayWidgetErrorBoundary.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface TodayWidgetErrorBoundaryProps { + children: ReactNode; + fallback: ReactNode; +} + +interface TodayWidgetErrorBoundaryState { + hasError: boolean; +} + +export class TodayWidgetErrorBoundary extends Component< + TodayWidgetErrorBoundaryProps, + TodayWidgetErrorBoundaryState +> { + state: TodayWidgetErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): TodayWidgetErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Today widget render error:', error, info); + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} diff --git a/frontend/src/components/today/chart-day-labels.ts b/frontend/src/components/today/chart-day-labels.ts new file mode 100644 index 0000000..1a5b173 --- /dev/null +++ b/frontend/src/components/today/chart-day-labels.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useMemo } from 'react'; + +export function useTodayDayLabelFormatter() { + return useMemo( + () => + new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }), + [], + ); +} + +export function formatTodayChartDayLabel( + code: string, + formatter: Intl.DateTimeFormat, +): string { + const [year, month, day] = code.split('-').map(Number); + if (!year || !month || !day) return code; + return formatter.format(new Date(year, month - 1, day)); +} + +export function mapWeekChartBuckets( + buckets: T[], + formatter: Intl.DateTimeFormat, +): T[] { + return buckets.map((bucket) => ({ + ...bucket, + label: formatTodayChartDayLabel(bucket.code, formatter), + })); +} diff --git a/frontend/src/components/today/chart-theme.ts b/frontend/src/components/today/chart-theme.ts new file mode 100644 index 0000000..50bef8c --- /dev/null +++ b/frontend/src/components/today/chart-theme.ts @@ -0,0 +1,58 @@ +import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors'; + +/** Chart series colors — same palette as treatment / prosthesis catalog types. */ +export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS; + +/** + * Rank-based charts (efficiency report, appointments by provider): same hex pool as + * CATALOG_PALETTE_COLORS, reordered so consecutive ranks are visually distinct. + */ +const CHART_RANK_COLOR_ORDER = [ + '#fed7aa', // peach + '#93c5fd', // blue + '#86efac', // green + '#c4b5fd', // purple + '#f9a8d4', // pink + '#bae6fd', // sky + '#fde68a', // yellow + '#99f6e4', // teal + '#fca5a5', // salmon + '#ddd6fe', // lavender + '#fdba74', // orange — separated from peach + '#a5b4fc', // indigo + '#cbd5e1', // slate + '#d9f99d', // lime + '#fecaca', // light coral + '#fbcfe8', // pale pink +] as const; + +const chartRankColorSet = new Set(CHART_RANK_COLOR_ORDER); + +export const TODAY_CHART_RANK_COLORS: readonly string[] = [ + ...CHART_RANK_COLOR_ORDER, + ...CATALOG_PALETTE_COLORS.filter((color) => !chartRankColorSet.has(color)), +]; + +export function chartRankColor(index: number): string { + return TODAY_CHART_RANK_COLORS[index % TODAY_CHART_RANK_COLORS.length]; +} + +/** Primary accent for single-series charts (area, gauge). */ +export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd'; + +/** Lab task activity series (completed / received). */ +export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac'; +export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd'; + +export const TODAY_CHART_AXIS_COLOR = '#8ea3bf'; +export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)'; +export const TODAY_CHART_TOOLTIP_BG = '#14253d'; +export const TODAY_CHART_TOOLTIP_BORDER = '#29456a'; + +export const TODAY_CHART_TOOLTIP_STYLE = { + backgroundColor: TODAY_CHART_TOOLTIP_BG, + border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`, + borderRadius: '6px', + color: '#f5f9ff', + fontSize: '12px', +} as const; 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..69a480a --- /dev/null +++ b/frontend/src/components/today/today-dashboard-layout.ts @@ -0,0 +1,144 @@ +import type { ReactNode } from 'react'; +import { getTodayGadgetFeatureOrder } from '@/components/today/today-gadget-order'; + +/** 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 }, + /** Week area charts (appointments, lab task activity). */ + chartArea: { width: 2, height: 2 }, + /** Vertical / horizontal bar charts. */ + chartBar: { width: 2, height: 3 }, + /** @deprecated Prefer chartArea (height 2) or chartBar (height 3). */ + chart: { width: 2, height: 3 }, + /** @deprecated Use chartArea or chartBar */ + 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; + + const byFeature = getTodayGadgetFeatureOrder(a.id) - getTodayGadgetFeatureOrder(b.id); + if (byFeature !== 0) return byFeature; + + 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/today-gadget-order.ts b/frontend/src/components/today/today-gadget-order.ts new file mode 100644 index 0000000..9d6d153 --- /dev/null +++ b/frontend/src/components/today/today-gadget-order.ts @@ -0,0 +1,78 @@ +import type { TodayWidgetKey } from '@/types/today'; + +/** + * Feature domains for Today dashboard gadgets, ordered like app permissions: + * owner-only → staff → organizations → patients → appointments → treatment → cases → tasks + */ +export type TodayGadgetFeature = + | 'owner' + | 'staff' + | 'organizations' + | 'patients' + | 'appointments' + | 'treatment' + | 'cases' + | 'tasks'; + +export const TODAY_GADGET_FEATURE_SORT_ORDER: Record = { + owner: 0, + staff: 10, + organizations: 20, + patients: 30, + appointments: 40, + treatment: 50, + cases: 60, + tasks: 70, +}; + +/** KPI widgets — keyed by TodayWidgetKey. */ +export const TODAY_KPI_GADGET_FEATURE: Record = { + appointmentsToday: 'appointments', + patientsToday: 'patients', + treatmentsToday: 'treatment', + labCasesPendingSend: 'treatment', + providersWithoutWorkingHours: 'staff', + casesReceivedToday: 'cases', + casesInProgress: 'cases', + tasksInProgress: 'tasks', + importantTasks: 'tasks', + pendingConnections: 'organizations', + pendingStaffInvites: 'staff', +}; + +/** Charts and composite gadgets — keyed by stable cell id. */ +export const TODAY_GADGET_ID_FEATURE: Record = { + subscription: 'owner', + 'case-completion': 'cases', + 'treatment-plan-completion': 'treatment', + 'upcoming-appointments': 'treatment', + 'chart-efficiency-report': 'owner', + 'chart-appointments-week-all': 'appointments', + 'chart-appointments-week-mine': 'treatment', + 'chart-appointments-by-provider': 'appointments', + 'chart-treatment-mix': 'treatment', + 'chart-lab-task-activity': 'cases', + 'chart-tasks-by-prosthesis': 'tasks', + 'chart-case-partners-month': 'treatment', +}; + +export function todayGadgetFeatureSortRank(feature: TodayGadgetFeature): number { + return TODAY_GADGET_FEATURE_SORT_ORDER[feature]; +} + +export function getTodayGadgetFeatureOrder(gadgetId: string): number { + const direct = TODAY_GADGET_ID_FEATURE[gadgetId]; + if (direct) { + return todayGadgetFeatureSortRank(direct); + } + + if (gadgetId.startsWith('kpi-')) { + const key = gadgetId.slice(4) as TodayWidgetKey; + const feature = TODAY_KPI_GADGET_FEATURE[key]; + if (feature) { + return todayGadgetFeatureSortRank(feature); + } + } + + return Number.MAX_SAFE_INTEGER; +} diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts new file mode 100644 index 0000000..aa27100 --- /dev/null +++ b/frontend/src/components/today/widget-registry.ts @@ -0,0 +1,224 @@ +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 { + canEditStaff, + canViewAppointmentsTab, + 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[]; + href: string; + 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'], + href: '/appointments', + isVisible: (org) => canViewAppointmentsTab(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'appointmentsToday'); + return count === null ? null : String(count); + }, + }, + { + key: 'patientsToday', + titleKey: 'widgetPatientsToday', + icon: Users, + color: 'green', + orgTypes: ['CLINIC'], + href: '/patients', + isVisible: (org) => canViewPatients(org) || canViewAppointmentsTab(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'patientsToday'); + return count === null ? null : String(count); + }, + }, + { + key: 'treatmentsToday', + titleKey: 'widgetTreatmentsToday', + icon: Stethoscope, + color: 'purple', + orgTypes: ['CLINIC'], + href: '/treatment', + isVisible: (org) => canViewTreatment(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'treatmentsToday'); + return count === null ? null : String(count); + }, + }, + { + key: 'labCasesPendingSend', + titleKey: 'widgetLabCasesPendingSend', + icon: FlaskConical, + color: 'red', + orgTypes: ['CLINIC'], + href: '/treatment', + isVisible: (org) => canViewTreatment(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'labCasesPendingSend'); + return count === null ? null : String(count); + }, + }, + { + key: 'providersWithoutWorkingHours', + titleKey: 'widgetProvidersWithoutWorkingHours', + icon: UserCog, + color: 'yellow', + orgTypes: ['CLINIC'], + href: '/staff', + isVisible: (org) => canViewStaff(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'providersWithoutWorkingHours'); + return count === null ? null : String(count); + }, + }, + { + key: 'casesReceivedToday', + titleKey: 'widgetCasesReceivedToday', + icon: FlaskConical, + color: 'blue', + orgTypes: ['LAB'], + href: '/cases', + isVisible: (org) => canViewCases(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'casesReceivedToday'); + return count === null ? null : String(count); + }, + }, + { + key: 'casesInProgress', + titleKey: 'widgetCasesInProgress', + icon: FlaskConical, + color: 'yellow', + orgTypes: ['LAB'], + href: '/cases', + isVisible: (org) => canViewCases(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'casesInProgress'); + return count === null ? null : String(count); + }, + }, + { + key: 'tasksInProgress', + titleKey: 'widgetTasksInProgress', + icon: ClipboardList, + color: 'yellow', + orgTypes: ['LAB'], + href: '/tasks', + 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'], + href: '/tasks', + 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'], + href: '/organizations', + isVisible: (org) => canManageOrganizations(org), + formatValue: (widgets) => { + const count = countWidget(widgets, 'pendingConnections'); + return count === null ? null : String(count); + }, + }, + { + key: 'pendingStaffInvites', + titleKey: 'widgetPendingStaffInvites', + icon: UserCog, + color: 'purple', + orgTypes: ['CLINIC', 'LAB'], + href: '/staff', + 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/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 7113849..dc006fa 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -19,7 +19,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, @@ -79,7 +79,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) { return false; } if (item.path === '/appointments') { - return canAccessAppointmentsSection(currentOrganization); + return canViewAppointmentsTab(currentOrganization); } if (item.path === '/cases') { return canViewCases(currentOrganization); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 80527d6..75f6b45 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; @@ -254,10 +255,16 @@ function detailsToPreviewTreatment( interface TreatmentWorkspaceProps { userId: string; currentOrganization: Organization | null; + initialAppointmentId?: string | null; } -export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { +export function TreatmentWorkspace({ + userId, + currentOrganization, + initialAppointmentId = null, +}: TreatmentWorkspaceProps) { const t = useTranslations('treatment'); + const router = useRouter(); const { showError, showSuccess, messages: toastMessages } = useToast(); const canView = canViewTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization); @@ -308,6 +315,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const labCaseDraftsRef = useRef(labCaseDrafts); labCaseDraftsRef.current = labCaseDrafts; const skipNextGetDraftRef = useRef(false); + const pendingAppointmentIdRef = useRef(initialAppointmentId); + + useEffect(() => { + pendingAppointmentIdRef.current = initialAppointmentId; + if (initialAppointmentId) { + setSelectedDay(startOfLocalDay(new Date())); + setSelectionLocked(false); + } + }, [initialAppointmentId]); const [sendBusyId, setSendBusyId] = useState(null); const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null); @@ -464,7 +480,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor .map(mapAppointment); setAppointments(list); if (!selectionLockedRef.current) { - setSelectedAppointmentId(pickAutoAppointment(list, selectedDay)); + const pendingId = pendingAppointmentIdRef.current; + if (pendingId && list.some((appointment) => appointment.id === pendingId)) { + setSelectedAppointmentId(pendingId); + setSelectionLocked(true); + pendingAppointmentIdRef.current = null; + router.replace('/treatment', { scroll: false }); + } else { + pendingAppointmentIdRef.current = null; + setSelectedAppointmentId(pickAutoAppointment(list, selectedDay)); + } } } catch (error: unknown) { if (!cancelled) { @@ -477,7 +502,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor return () => { cancelled = true; }; - }, [userId, selectedDay, showError, t]); + }, [userId, selectedDay, showError, t, router]); useEffect(() => { const today = startOfLocalDay(new Date()); diff --git a/frontend/src/components/ui/treatment/catalog-type-colors.ts b/frontend/src/components/ui/treatment/catalog-type-colors.ts new file mode 100644 index 0000000..5ae0452 --- /dev/null +++ b/frontend/src/components/ui/treatment/catalog-type-colors.ts @@ -0,0 +1,102 @@ +/** + * Shared pastel palette for treatment types, prosthesis types, and dashboard charts. + * Treatment and prosthesis each have dedicated hex maps — prosthesis colors are unique + * within the prosthesis catalog (no duplicate swatches on charts or badges). + */ + +export const TREATMENT_TYPE_COLORS: Record = { + restoration: '#fed7aa', + specialized_restoration: '#fdba74', + radiography: '#cbd5e1', + endo: '#fecaca', + surgery: '#fca5a5', + prosthesis: '#c4b5fd', + implant: '#a5b4fc', + orthodontics: '#93c5fd', + perio: '#86efac', + pediatrics: '#fde68a', + extraction: '#f9a8d4', + clinic_visit: '#bae6fd', + continue_treatment: '#99f6e4', +}; + +/** Dedicated prosthesis palette — one distinct pastel per catalog code. */ +export const PROSTHESIS_TYPE_COLORS: Record = { + pfm_crown: '#e2e8f0', + pfz_crown: '#bbf7d0', + monolithic_zirconia: '#e0f2fe', + glass_ceramic_crown: '#fef08a', + full_metal_crown: '#d4d4d8', + temporary_resin_crown: '#bae6fd', + pmma: '#7dd3fc', + peek_crown: '#5eead4', + veneer_zirconia: '#6ee7b7', + veneer_ips_press: '#fed7aa', + veneer_ips_cad: '#fdba74', + soft_structure: '#ddd6fe', + customized_abutment: '#a5b4fc', + prefabricated_abutment: '#c7d2fe', + ti_base_abutment: '#bfdbfe', + multi_unit_abutment: '#818cf8', + zirconia_abutment: '#34d399', + screw_retained: '#e9d5ff', + zirconia_overlay: '#2dd4bf', + ips_overlay: '#fef3c7', + smile_design: '#f9a8d4', + mockup: '#fbcfe8', +}; + +export const CATALOG_FALLBACK_COLORS = [ + '#ddd6fe', + '#fed7aa', + '#fecaca', + '#bae6fd', + '#d9f99d', + '#fbcfe8', +] as const; + +/** Fallback rotation for unknown prosthesis codes — drawn from the prosthesis palette. */ +export const PROSTHESIS_FALLBACK_COLORS: readonly string[] = [ + ...new Set(Object.values(PROSTHESIS_TYPE_COLORS)), +]; + +/** Ordered palette for charts and rotating unknown treatment catalog codes. */ +export const CATALOG_PALETTE_COLORS: readonly string[] = [ + '#fed7aa', + '#fdba74', + '#cbd5e1', + '#fecaca', + '#fca5a5', + '#c4b5fd', + '#a5b4fc', + '#93c5fd', + '#86efac', + '#fde68a', + '#f9a8d4', + '#bae6fd', + '#99f6e4', + '#ddd6fe', + '#d9f99d', + '#fbcfe8', + ...PROSTHESIS_FALLBACK_COLORS.filter( + (color) => + ![ + '#fed7aa', + '#fdba74', + '#bae6fd', + '#f9a8d4', + '#ddd6fe', + '#fbcfe8', + '#a5b4fc', + ].includes(color), + ), +]; + +export function resolveCatalogTypeColor( + code: string, + colorMap: Record, + index = 0, + fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS, +): string { + return colorMap[code] ?? fallbackColors[index % fallbackColors.length]; +} diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts index b60f09f..278c83e 100644 --- a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts @@ -1,56 +1,22 @@ import type { CSSProperties } from 'react'; +import { + PROSTHESIS_FALLBACK_COLORS, + PROSTHESIS_TYPE_COLORS, + resolveCatalogTypeColor, +} from '@/components/ui/treatment/catalog-type-colors'; /** * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group - * headers / badges). Grouped by material family, loosely inspired by exocad's - * material color conventions: - * - Zirconia family → pale green/cream - * - PFM / full metal → steel gray - * - Glass-ceramic / IPS (press & CAD) → warm amber - * - Resin / PMMA / PEEK / temporary → mint/teal - * - Abutments / screw-retained → slate blue - * - Smile design / mockup → lavender/pink + * headers / badges). Uses a dedicated pastel map (unique per prosthesis code). * * Clinic-facing dispatch flows intentionally do NOT use these colors. */ -const PROSTHESIS_TYPE_COLORS: Record = { - // Zirconia family - monolithic_zirconia: '#d9f2e6', - pfz_crown: '#c7ede0', - veneer_zirconia: '#b8e6d5', - zirconia_abutment: '#a7dcc8', - zirconia_overlay: '#cdeede', - // PFM / metal - pfm_crown: '#cbd5e1', - full_metal_crown: '#b8c2cf', - // Glass-ceramic / IPS - glass_ceramic_crown: '#fde3a7', - veneer_ips_press: '#fcd88f', - veneer_ips_cad: '#f9cf9c', - ips_overlay: '#fbe0b0', - // Resin / PMMA / PEEK / temporary - temporary_resin_crown: '#bfeaf0', - pmma: '#a9e2ea', - peek_crown: '#b7e4dd', - soft_structure: '#d4eef0', - // Abutments / screw-retained - customized_abutment: '#aec6e8', - prefabricated_abutment: '#9db8e0', - ti_base_abutment: '#c0d0ec', - multi_unit_abutment: '#b4c4e6', - screw_retained: '#a8bce2', - // Design / mockup - smile_design: '#e9d5ff', - mockup: '#f5d0fe', -}; - -const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8']; /** Dark ink that stays readable on every pastel in the palette. */ const BADGE_INK = '#14253d'; export function prosthesisTypeColor(code: string, index = 0): string { - return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length]; + return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS); } /** Filled swatch (small indicator dots). */ diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts index bcf90eb..ff2ca0a 100644 --- a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts +++ b/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts @@ -1,5 +1,9 @@ import type { CSSProperties } from 'react'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + resolveCatalogTypeColor, + TREATMENT_TYPE_COLORS, +} from '@/components/ui/treatment/catalog-type-colors'; /** * Single source of truth for treatment-type colors across the app @@ -10,23 +14,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; * conventions), so this is a curated pastel palette. Extend it as new treatment * types are added; unknown codes fall back to a rotating pastel set by index. */ -const TREATMENT_TYPE_COLORS: Record = { - restoration: '#fed7aa', - specialized_restoration: '#fdba74', - radiography: '#cbd5e1', - endo: '#fecaca', - surgery: '#fca5a5', - prosthesis: '#c4b5fd', - implant: '#a5b4fc', - orthodontics: '#93c5fd', - perio: '#86efac', - pediatrics: '#fde68a', - extraction: '#f9a8d4', - clinic_visit: '#bae6fd', - continue_treatment: '#99f6e4', -}; - -const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8']; /** Dark ink that stays readable on every pastel in the palette. */ const BANNER_INK = '#14253d'; @@ -34,7 +21,7 @@ const BANNER_INK = '#14253d'; export const DROPDOWN_OPTION_BG = '#14253d'; export function treatmentTypeColor(code: string, index = 0): string { - return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length]; + return resolveCatalogTypeColor(code, TREATMENT_TYPE_COLORS, index); } /** Filled swatch (legend dots, small indicators). */ diff --git a/frontend/src/lib/api/today.ts b/frontend/src/lib/api/today.ts new file mode 100644 index 0000000..34889a7 --- /dev/null +++ b/frontend/src/lib/api/today.ts @@ -0,0 +1,15 @@ +import { apiClient } from './client'; +import type { TodaySummaryResponse } from '@/types/today'; + +export interface TodaySummaryParams { + from: string; + to: string; + utcOffsetMinutes?: number; +} + +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..2fe78ea --- /dev/null +++ b/frontend/src/lib/hooks/useTodaySummary.ts @@ -0,0 +1,64 @@ +'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; + isInitialLoad: boolean; + error: ApiError | null; + reload: () => Promise; +} + +export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult { + const enabled = Boolean(organizationId); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + if (!organizationId) { + setData(null); + setLoading(false); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const range = getLocalDayIsoRange(new Date()); + const utcOffsetMinutes = -new Date().getTimezoneOffset(); + const response = await todayApi.summary({ ...range, utcOffsetMinutes }); + setData(response.data); + } catch (err) { + setError(err as ApiError); + } finally { + setLoading(false); + } + }, [organizationId]); + + useEffect(() => { + setData(null); + setError(null); + if (!organizationId) { + setLoading(false); + return; + } + setLoading(true); + void reload(); + }, [organizationId, reload]); + + return { + data, + loading, + isInitialLoad: loading && !data, + error, + reload, + }; +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index f185186..20e9786 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; @@ -280,6 +280,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 new file mode 100644 index 0000000..5600a8d --- /dev/null +++ b/frontend/src/types/today.ts @@ -0,0 +1,100 @@ +export type TodayUpcomingAppointment = { + id: string; + patientName: string; + startAt: string; + endAt: string; + purpose: string; +}; + +export type TodaySummaryActions = { + upcomingAppointmentsToday?: TodayUpcomingAppointment[]; +}; + +export type TodayChartBucket = { + code: string; + label: string; + count: number; +}; + +export type TodayStackedDayBucket = { + code: string; + label: string; + completed: number; + received: number; +}; + +export type TodayPartnerCasesBucket = { + code: string; + label: string; + completed: number; + pending: number; +}; + +export type TodayCompletionGauge = { + completed: number; + total: number; + percent: number; +}; + +export type TodaySummaryCharts = { + treatmentMixWeek?: TodayChartBucket[]; + tasksByProsthesis?: TodayChartBucket[]; + appointmentsByProvider?: TodayChartBucket[]; + caseCompletion?: TodayCompletionGauge; + treatmentPlanCompletion?: TodayCompletionGauge; + appointmentsWeekAll?: TodayChartBucket[]; + appointmentsWeekMine?: TodayChartBucket[]; + labTaskActivityWeek?: TodayStackedDayBucket[]; + casePartnersMonth?: TodayPartnerCasesBucket[]; + efficiencyReport?: TodayChartBucket[]; +}; + +export type TodayWidgetKey = + | 'appointmentsToday' + | 'patientsToday' + | 'treatmentsToday' + | 'labCasesPendingSend' + | 'casesReceivedToday' + | 'casesInProgress' + | 'tasksInProgress' + | 'importantTasks' + | 'pendingConnections' + | '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, + | { count: number } + | { used: number; limit: number | null; unlimited: boolean } + > +>; + +export interface TodaySummaryData { + generatedAt: string; + orgType: 'CLINIC' | 'LAB'; + range: { from: string; to: string }; + widgets: TodaySummaryWidgets; + charts: TodaySummaryCharts; + actions: TodaySummaryActions; + subscription?: TodaySubscriptionSnapshot; +} + +export interface TodaySummaryResponse { + success: boolean; + data: TodaySummaryData; +}