diff --git a/backend/src/modules/today/dto/today-summary-query.dto.ts b/backend/src/modules/today/dto/today-summary-query.dto.ts index 4a311e2..9beef55 100644 --- a/backend/src/modules/today/dto/today-summary-query.dto.ts +++ b/backend/src/modules/today/dto/today-summary-query.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsISO8601, IsOptional } from 'class-validator'; +import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; export class TodaySummaryQueryDto { @ApiPropertyOptional({ @@ -17,4 +18,16 @@ export class TodaySummaryQueryDto { @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.module.ts b/backend/src/modules/today/today.module.ts index 341a7ea..dd6988f 100644 --- a/backend/src/modules/today/today.module.ts +++ b/backend/src/modules/today/today.module.ts @@ -1,8 +1,10 @@ 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], }) diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 8e469a8..0f97f6f 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -18,13 +18,33 @@ import { 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 CaseCompletionChart = { + completed: number; + total: number; + percent: number; +}; + type TodayCharts = { treatmentMixWeek?: ChartBucket[]; tasksByWorkflowStep?: ChartBucket[]; + appointmentsByProvider?: ChartBucket[]; + caseCompletion?: CaseCompletionChart; + appointmentsWeekAll?: ChartBucket[]; + appointmentsWeekMine?: ChartBucket[]; + labTaskActivityWeek?: StackedDayBucket[]; + inProgressTasksByProsthesis?: ChartBucket[]; }; type TodayActions = { @@ -44,11 +64,13 @@ type TodayWidgets = { draftTreatments?: { count: number }; labCasesPendingSend?: { count: number }; casesReceivedToday?: { count: number }; + casesInProgress?: { count: number }; tasksInProgress?: { count: number }; importantTasks?: { count: number }; pendingConnections?: { count: number }; seats?: { used: number; limit: number | null; unlimited: boolean }; pendingStaffInvites?: { count: number }; + providersWithoutWorkingHours?: { count: number }; }; @Injectable() @@ -56,6 +78,7 @@ export class TodayService { constructor( private readonly prisma: PrismaService, private readonly catalogLabels: CatalogLabelService, + private readonly staffWorkingHoursService: StaffWorkingHoursService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -90,16 +113,15 @@ export class TodayService { this.loadAppointmentsToday(organizationId, from, to, widgets), ); tasks.push( - this.loadUpcomingAppointmentsToday(organizationId, from, to, actions), + this.loadAppointmentsByProvider(organizationId, from, to, charts), ); - } - - if ( - this.canViewPatients(membership.isOwner, permissionNames) || - this.canViewAppointments(membership.isOwner, permissionNames) - ) { tasks.push( - this.loadPatientsToday(organizationId, from, to, widgets), + this.loadAppointmentsWeekAll( + organizationId, + to, + query.utcOffsetMinutes, + charts, + ), ); } @@ -112,14 +134,53 @@ export class TodayService { tasks.push( this.loadTreatmentMixWeek(organizationId, to, locale, charts), ); + tasks.push( + this.loadAppointmentsWeekMine( + organizationId, + userId, + to, + query.utcOffsetMinutes, + charts, + ), + ); + tasks.push( + this.loadUpcomingAppointmentsToday( + organizationId, + userId, + from, + to, + actions, + ), + ); + } + + 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.canViewTasks(membership.isOwner, permissionNames)) { @@ -127,6 +188,20 @@ export class TodayService { tasks.push(this.loadImportantTasks(organizationId, widgets)); tasks.push(this.loadTasksByWorkflowStep(organizationId, charts)); } + + if (canViewLabWork) { + tasks.push( + this.loadLabTaskActivityWeek( + organizationId, + to, + query.utcOffsetMinutes, + charts, + ), + ); + tasks.push( + this.loadInProgressTasksByProsthesis(organizationId, locale, charts), + ); + } } if (this.canManageOrganizations(membership.isOwner, permissionNames)) { @@ -264,6 +339,7 @@ export class TodayService { private async loadUpcomingAppointmentsToday( organizationId: string, + providerUserId: string, from: Date, to: Date, actions: TodayActions, @@ -272,6 +348,7 @@ export class TodayService { const items = await this.prisma.appointment.findMany({ where: { organizationId, + providerUserId, startAt: { lt: to }, endAt: { gt: now > from ? now : from }, }, @@ -279,7 +356,7 @@ export class TodayService { patient: { select: { firstName: true, lastName: true } }, }, orderBy: { startAt: 'asc' }, - take: 5, + take: 3, }); actions.upcomingAppointmentsToday = items.map((appointment) => ({ @@ -448,6 +525,303 @@ export class TodayService { 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 }, + }); + + const total = tasks.length; + const completed = tasks.filter( + (task) => task.status === LabTaskStatus.COMPLETED, + ).length; + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + + charts.caseCompletion = { completed, total, percent }; + } + + 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 loadInProgressTasksByProsthesis( + 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.inProgressTasksByProsthesis = []; + return; + } + + const labels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.PROSTHESIS_TYPE, + sorted.map((row) => row.code), + locale, + ); + + charts.inProgressTasksByProsthesis = sorted.map((row) => ({ + code: row.code, + label: labels.get(row.code) ?? row.code, + count: row.count, + })); + } + private getRequesterOrganizationId(sharedDataTypes: unknown): string | null { if (!sharedDataTypes || typeof sharedDataTypes !== 'object') { return null; @@ -567,3 +941,37 @@ function aggregateCount( 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/frontend/messages/en.json b/frontend/messages/en.json index f3127f3..6381d7a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -203,13 +203,31 @@ "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", "widgetSeats": "Seat Usage", "widgetPendingStaffInvites": "Pending Staff Invites", + "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", + "chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis", + "chartProsthesisMixSubtitle": "Current workload mix", + "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", "chartTasksByStepTitle": "Tasks by Workflow Step", "chartTasksByStepSubtitle": "In progress now", "chartEmpty": "No data for this period yet.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 4836a03..41aa956 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -203,13 +203,31 @@ "widgetDraftTreatments": "درمان‌های پیش‌نویس", "widgetLabCasesPendingSend": "پرونده‌های در انتظار ارسال", "widgetCasesReceivedToday": "پرونده‌های دریافتی امروز", + "widgetCasesInProgress": "پرونده‌های در حال انجام", "widgetTasksInProgress": "وظایف در حال انجام", "widgetImportantTasks": "وظایف مهم", "widgetPendingConnections": "درخواست‌های اتصال در انتظار", + "widgetProvidersWithoutWorkingHours": "ارائه‌دهندگان بدون ساعات کاری", "widgetSeats": "استفاده از صندلی", "widgetPendingStaffInvites": "دعوت‌های کارکنان در انتظار", + "chartAppointmentsWeekAllTitle": "نوبت‌های این هفته", + "chartAppointmentsWeekAllSubtitle": "همه ارائه‌دهندگان — ۷ روز گذشته", + "chartAppointmentsWeekMineTitle": "نوبت‌های من این هفته", + "chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته", + "chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه", + "chartLabTaskActivitySubtitle": "۷ روز گذشته", + "chartLabTaskCompletedLegend": "تکمیل‌شده", + "chartLabTaskReceivedLegend": "دریافت‌شده", + "chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز", + "chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی", + "chartAppointmentsByProviderTitle": "نوبت‌ها بر اساس ارائه‌دهنده", + "chartAppointmentsByProviderSubtitle": "امروز", "chartTreatmentMixTitle": "ترکیب درمان‌ها", "chartTreatmentMixSubtitle": "۷ روز گذشته", + "chartCaseCompletionTitle": "تکمیل پرونده‌ها", + "chartCaseCompletionSubtitle": "همه پرونده‌های فعال", + "chartCaseCompletionPercent": "{percent}٪", + "chartCaseCompletionTasks": "وظایف تکمیل‌شده", "chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار", "chartTasksByStepSubtitle": "در حال انجام", "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a285a55..cf5e5f4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -203,13 +203,31 @@ "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", "widgetSeats": "Zitplaatsgebruik", "widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen", + "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", + "chartProsthesisMixTitle": "Lopende taken per prothese", + "chartProsthesisMixSubtitle": "Huidige werklastmix", + "chartAppointmentsByProviderTitle": "Afspraken per behandelaar", + "chartAppointmentsByProviderSubtitle": "Vandaag", "chartTreatmentMixTitle": "Behandelingsmix", "chartTreatmentMixSubtitle": "Afgelopen 7 dagen", + "chartCaseCompletionTitle": "Casevoltooiing", + "chartCaseCompletionSubtitle": "Alle actieve cases", + "chartCaseCompletionPercent": "{percent}%", + "chartCaseCompletionTasks": "Taken voltooid", "chartTasksByStepTitle": "Taken per workflowstap", "chartTasksByStepSubtitle": "Nu in uitvoering", "chartEmpty": "Nog geen gegevens voor deze periode.", diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 192a324..1d934a7 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -6,6 +6,9 @@ import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { canAccessAppointmentsSection, + canViewAppointmentsTab, + canViewCases, + canViewLabCasesOrTasks, canViewTasks, canViewTreatment, } from '@/components/shared/permissions'; @@ -27,17 +30,26 @@ export default function TodayPage() { const showNoSubscriptionNotice = Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; - const showUpcoming = canAccessAppointmentsSection(currentOrganization); - const showCharts = - (currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization)) || - (currentOrganization?.type === 'LAB' && canViewTasks(currentOrganization)); + const showUpcoming = + currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization); + const showCharts = useMemo(() => { + const orgType = currentOrganization?.type; + if (!orgType) return false; - const actionLayoutClass = useMemo(() => { - if (showUpcoming && showCharts) { - return 'grid grid-cols-1 xl:grid-cols-2 gap-4 items-start'; + if (orgType === 'CLINIC') { + return ( + canAccessAppointmentsSection(currentOrganization) || + canViewAppointmentsTab(currentOrganization) || + canViewTreatment(currentOrganization) + ); } - return 'grid grid-cols-1 gap-4'; - }, [showUpcoming, showCharts]); + + return ( + canViewCases(currentOrganization) || + canViewTasks(currentOrganization) || + canViewLabCasesOrTasks(currentOrganization) + ); + }, [currentOrganization]); const sectionErrorMessage = t('sectionLoadError'); @@ -89,33 +101,38 @@ export default function TodayPage() { /> - {(showUpcoming || showCharts) && (!error || data) ? ( -
- {showUpcoming ? ( - } - > - - - ) : null} - - {showCharts ? ( - } - > + {showUpcoming && (!error || data) ? ( + } + > +
+ + {showCharts ? ( - - ) : null} -
+ ) : null} +
+ + ) : showCharts && (!error || 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..31d4684 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -210,3 +210,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 index 6d15d20..d15a29c 100644 --- a/frontend/src/components/today/ChartCard.tsx +++ b/frontend/src/components/today/ChartCard.tsx @@ -37,7 +37,7 @@ export function ChartCard({

{emptyMessage}

) : ( -
{children}
+
{children}
)} ); diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/today/KpiCard.tsx index 53ca99d..3bb75c4 100644 --- a/frontend/src/components/today/KpiCard.tsx +++ b/frontend/src/components/today/KpiCard.tsx @@ -37,7 +37,12 @@ export function KpiCard({ >

{title}

- {Icon ? : null} + {Icon ? ( + + ) : null}
{loading ? (
diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/today/TodayAreaChart.tsx new file mode 100644 index 0000000..9c39b0d --- /dev/null +++ b/frontend/src/components/today/TodayAreaChart.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +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[]; +} + +export function TodayAreaChart({ data }: TodayAreaChartProps) { + return ( + + + + + + + + + + + + String(label)} + /> + + + + ); +} diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/today/TodayBarChart.tsx index ca997ed..2d00030 100644 --- a/frontend/src/components/today/TodayBarChart.tsx +++ b/frontend/src/components/today/TodayBarChart.tsx @@ -21,9 +21,10 @@ import { interface TodayBarChartProps { data: TodayChartBucket[]; + colorForCode?: (code: string, index: number) => string; } -export function TodayBarChart({ data }: TodayBarChartProps) { +export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) { const chartData = data.map((item) => ({ ...item, shortLabel: truncateLabel(item.label), @@ -38,7 +39,35 @@ export function TodayBarChart({ data }: TodayBarChartProps) { { + 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} @@ -68,7 +97,10 @@ export function TodayBarChart({ data }: TodayBarChartProps) { {chartData.map((entry, index) => ( ))} diff --git a/frontend/src/components/today/TodayChartsSection.tsx b/frontend/src/components/today/TodayChartsSection.tsx index 5ae13e2..1b2b943 100644 --- a/frontend/src/components/today/TodayChartsSection.tsx +++ b/frontend/src/components/today/TodayChartsSection.tsx @@ -1,14 +1,23 @@ 'use client'; +import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; -import { - canViewTasks, - canViewTreatment, -} from '@/components/shared/permissions'; import { ChartCard } from '@/components/today/ChartCard'; +import { TodayAreaChart } from '@/components/today/TodayAreaChart'; import { TodayBarChart } from '@/components/today/TodayBarChart'; +import { + formatTodayChartDayLabel, + mapWeekChartBuckets, + useTodayDayLabelFormatter, +} from '@/components/today/chart-day-labels'; +import { TodayDonutChart } from '@/components/today/TodayDonutChart'; +import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; +import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart'; import { ChartCardSkeleton } from '@/components/today/TodaySkeleton'; +import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; import type { TodaySummaryCharts } from '@/types/today'; interface TodayChartsSectionProps { @@ -16,6 +25,8 @@ interface TodayChartsSectionProps { loading?: boolean; isInitialLoad?: boolean; className?: string; + /** When true, chart cards render as siblings (no outer grid wrapper). */ + embedded?: boolean; } export function TodayChartsSection({ @@ -23,38 +34,155 @@ export function TodayChartsSection({ loading = false, isInitialLoad = false, className = '', + embedded = false, }: TodayChartsSectionProps) { const t = useTranslations('today'); + const dayLabelFormatter = useTodayDayLabelFormatter(); const { currentOrganization } = useAuth(); const orgType = currentOrganization?.type; + const showAppointmentsByProvider = + orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined; + const showAppointmentsWeekAll = + orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined; + const showAppointmentsWeekMine = + orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined; const showTreatmentMix = - orgType === 'CLINIC' && canViewTreatment(currentOrganization); + orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined; + const showCaseCompletion = + orgType === 'LAB' && charts.caseCompletion !== undefined; const showTasksByStep = - orgType === 'LAB' && canViewTasks(currentOrganization); + orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined; + const showLabTaskActivityWeek = + orgType === 'LAB' && charts.labTaskActivityWeek !== undefined; + const showInProgressTasksByProsthesis = + orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined; - if (!showTreatmentMix && !showTasksByStep) { + const visibleChartCount = + Number(showAppointmentsByProvider) + + Number(showAppointmentsWeekAll) + + Number(showAppointmentsWeekMine) + + Number(showTreatmentMix) + + Number(showCaseCompletion) + + Number(showTasksByStep) + + Number(showLabTaskActivityWeek) + + Number(showInProgressTasksByProsthesis); + + const appointmentsByProviderData = charts.appointmentsByProvider ?? []; + const appointmentsWeekAllData = useMemo( + () => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter), + [charts.appointmentsWeekAll, dayLabelFormatter], + ); + const appointmentsWeekMineData = useMemo( + () => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter), + [charts.appointmentsWeekMine, dayLabelFormatter], + ); + const treatmentData = charts.treatmentMixWeek ?? []; + const tasksData = charts.tasksByWorkflowStep ?? []; + const labTaskActivityData = useMemo( + () => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter), + [charts.labTaskActivityWeek, dayLabelFormatter], + ); + const prosthesisData = charts.inProgressTasksByProsthesis ?? []; + const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 }; + + const formatDayLabel = (code: string) => + formatTodayChartDayLabel(code, dayLabelFormatter); + + if (visibleChartCount === 0) { return null; } - const treatmentData = charts.treatmentMixWeek ?? []; - const tasksData = charts.tasksByWorkflowStep ?? []; - if (isInitialLoad) { + const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => ( + + )); + + if (embedded) { + return <>{skeletons}; + } + return ( -
- {showTreatmentMix ? : null} - {showTasksByStep ? : null} -
+
{skeletons}
); } - const chartCount = (showTreatmentMix ? 1 : 0) + (showTasksByStep ? 1 : 0); + const gridClass = + visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1'; + + const chartCards = ( + <> + {showAppointmentsWeekAll ? ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ) : null} + + {showAppointmentsWeekMine ? ( + row.count === 0)} + emptyMessage={t('chartEmpty')} + > + + + ) : null} + + {showLabTaskActivityWeek ? ( + row.completed === 0 && row.received === 0, + )} + emptyMessage={t('chartEmpty')} + > + + + ) : null} + + {showInProgressTasksByProsthesis ? ( + + + prosthesisData.find((row) => row.code === code)?.label ?? code + } + colorForCode={(code, index) => prosthesisTypeColor(code, index)} + swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)} + variant="pie" + sideLegend + /> + + ) : null} + + {showAppointmentsByProvider ? ( + + + + ) : null} - return ( -
1 ? 'lg:grid-cols-2' : ''} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`} - > {showTreatmentMix ? ( - + treatmentTypeColor(code, index)} + /> + + ) : null} + + {showCaseCompletion ? ( + + ) : null} @@ -76,6 +226,18 @@ export function TodayChartsSection({ ) : null} + + ); + + if (embedded) { + return chartCards; + } + + return ( +
+ {chartCards}
); } diff --git a/frontend/src/components/today/TodayDonutChart.tsx b/frontend/src/components/today/TodayDonutChart.tsx new file mode 100644 index 0000000..b7acc9e --- /dev/null +++ b/frontend/src/components/today/TodayDonutChart.tsx @@ -0,0 +1,125 @@ +'use client'; + +import type { CSSProperties } from 'react'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import type { TodayChartBucket } from '@/types/today'; +import { + TODAY_CHART_COLORS, + TODAY_CHART_TOOLTIP_STYLE, +} from '@/components/today/chart-theme'; + +interface TodayDonutChartProps { + data: TodayChartBucket[]; + labelForCode: (code: string) => string; + colorForCode?: (code: string, index: number) => string; + swatchStyleForCode?: (code: string, index: number) => CSSProperties; + variant?: 'donut' | 'pie'; + sideLegend?: boolean; +} + +export function TodayDonutChart({ + data, + labelForCode, + colorForCode, + swatchStyleForCode, + variant = 'donut', + sideLegend = false, +}: TodayDonutChartProps) { + const chartData = data.map((item) => ({ + ...item, + displayLabel: labelForCode(item.code), + })); + + const resolveColor = (code: string, index: number) => + colorForCode?.(code, index) ?? + TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]; + + const resolveSwatchStyle = (code: string, index: number): CSSProperties => + swatchStyleForCode?.(code, index) ?? { + backgroundColor: resolveColor(code, index), + borderColor: 'rgba(0, 0, 0, 0.18)', + }; + + const innerRadius = variant === 'pie' ? 0 : 62; + const outerRadius = sideLegend ? 100 : 92; + + const chart = ( + + + + {chartData.map((entry, index) => ( + + ))} + + { + const row = item?.payload as TodayChartBucket | undefined; + return [value, row ? labelForCode(row.code) : '']; + }} + /> + + + ); + + if (!sideLegend) { + return chart; + } + + const rowClass = 'flex h-4 items-center text-xs leading-none'; + const legendInset = 'px-12'; + + return ( +
+
+
+ {chartData.map((entry, index) => ( + + + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.displayLabel} + + ))} +
+ +
+ {chartData.map((entry) => ( + + {entry.count} + + ))} +
+
+ +
+ {chart} +
+
+ ); +} diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/today/TodayHorizontalBarChart.tsx new file mode 100644 index 0000000..8b225ee --- /dev/null +++ b/frontend/src/components/today/TodayHorizontalBarChart.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { TodayChartBucket } from '@/types/today'; +import { + TODAY_CHART_AXIS_COLOR, + TODAY_CHART_COLORS, + 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/TodayRadialGaugeChart.tsx b/frontend/src/components/today/TodayRadialGaugeChart.tsx new file mode 100644 index 0000000..147dec9 --- /dev/null +++ b/frontend/src/components/today/TodayRadialGaugeChart.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { 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; +} + +export function TodayRadialGaugeChart({ + percent, + completed, + total, + percentLabel, + tasksLabel, +}: TodayRadialGaugeChartProps) { + const clamped = Math.max(0, Math.min(100, percent)); + const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }]; + + return ( +
+ + + + + +
+ {percentLabel} + {tasksLabel} + {total > 0 ? ( + + {completed}/{total} + + ) : null} +
+
+ ); +} diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/today/TodaySkeleton.tsx index 3a15438..61cb7d5 100644 --- a/frontend/src/components/today/TodaySkeleton.tsx +++ b/frontend/src/components/today/TodaySkeleton.tsx @@ -31,6 +31,6 @@ export function ChartCardSkeleton() { ); } -export function ListRowSkeleton() { - return ; +export function ListRowSkeleton({ compact = false }: { compact?: boolean }) { + return ; } diff --git a/frontend/src/components/today/TodayStackedBarChart.tsx b/frontend/src/components/today/TodayStackedBarChart.tsx new file mode 100644 index 0000000..dca7293 --- /dev/null +++ b/frontend/src/components/today/TodayStackedBarChart.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { TodayStackedDayBucket } from '@/types/today'; +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'; + +interface TodayStackedBarChartProps { + data: TodayStackedDayBucket[]; + completedLabel: string; + receivedLabel: string; + formatDayLabel: (code: string) => string; +} + +export function TodayStackedBarChart({ + data, + completedLabel, + receivedLabel, + formatDayLabel, +}: TodayStackedBarChartProps) { + const chartData = data.map((item) => ({ + ...item, + dayLabel: formatDayLabel(item.code), + })); + + return ( + + + + + + { + const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined; + return row ? formatDayLabel(row.code) : ''; + }} + /> + + + + + + ); +} diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/today/TodayUpcomingAppointments.tsx index be8d03f..b6e5d97 100644 --- a/frontend/src/components/today/TodayUpcomingAppointments.tsx +++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx @@ -1,13 +1,19 @@ '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 { canAccessAppointmentsSection } from '@/components/shared/permissions'; +import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection'; +import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { canViewTreatment } 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 { @@ -16,6 +22,8 @@ interface TodayUpcomingAppointmentsProps { isInitialLoad?: boolean; } +const MAX_VISIBLE = 3; + export function TodayUpcomingAppointments({ actions, loading = false, @@ -23,23 +31,35 @@ export function TodayUpcomingAppointments({ }: TodayUpcomingAppointmentsProps) { const t = useTranslations('today'); const { currentOrganization } = useAuth(); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); - if (!canAccessAppointmentsSection(currentOrganization)) { + useEffect(() => { + void treatmentCatalogApi + .list() + .then((response) => setTreatmentCatalog(response.data)) + .catch(() => {}); + }, []); + + if ( + !currentOrganization || + currentOrganization.type !== 'CLINIC' || + !canViewTreatment(currentOrganization) + ) { return null; } - const appointments = actions.upcomingAppointmentsToday ?? []; + const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE); if (isInitialLoad) { return ( - -
-
-
+ +
+
+
-
- {[0, 1, 2].map((key) => ( - +
+ {[0, 1].map((key) => ( + ))}
@@ -47,25 +67,25 @@ export function TodayUpcomingAppointments({ } return ( - -
+ +
-

+

{t('upcomingAppointmentsTitle')}

-

{t('upcomingAppointmentsSubtitle')}

+

{t('upcomingAppointmentsSubtitle')}

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

{t('noUpcomingAppointments')}

+
+

{t('noUpcomingAppointments')}

) : (
    @@ -73,26 +93,32 @@ export function TodayUpcomingAppointments({ 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 ? ( - · {appointment.purpose} + · {purposeDisplay} ) : null}

    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 index 664bebe..68ef694 100644 --- a/frontend/src/components/today/chart-theme.ts +++ b/frontend/src/components/today/chart-theme.ts @@ -1,16 +1,24 @@ -/** Bar fill colors aligned with the dark dashboard accent palette. */ -export const TODAY_CHART_COLORS = [ - '#00bcff', - '#e1bc72', - '#98d8b5', - '#cfb8f7', - '#f8c9a6', - '#f4b6b6', - '#abd8f3', - '#cbe9a1', -] as const; +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; + +/** Primary accent for single-series charts (area, gauge). */ +export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd'; + +/** Stacked bar segments for lab task activity. */ +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/widget-registry.ts b/frontend/src/components/today/widget-registry.ts index 2d8675d..8c9cf18 100644 --- a/frontend/src/components/today/widget-registry.ts +++ b/frontend/src/components/today/widget-registry.ts @@ -125,6 +125,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ 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', @@ -138,6 +151,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [ 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', diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index b03d4c6..efff046 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..61db449 --- /dev/null +++ b/frontend/src/components/ui/treatment/catalog-type-colors.ts @@ -0,0 +1,83 @@ +/** + * Shared pastel palette for treatment types, prosthesis types, and dashboard charts. + * Treatment types own the canonical hex values; prosthesis types reuse the same codes. + */ + +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', +}; + +/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */ +export const PROSTHESIS_TYPE_COLORS: Record = { + pfm_crown: '#cbd5e1', + pfz_crown: '#86efac', + monolithic_zirconia: '#99f6e4', + glass_ceramic_crown: '#fde68a', + full_metal_crown: '#cbd5e1', + temporary_resin_crown: '#bae6fd', + pmma: '#93c5fd', + peek_crown: '#99f6e4', + veneer_zirconia: '#86efac', + veneer_ips_press: '#fed7aa', + veneer_ips_cad: '#fdba74', + soft_structure: '#ddd6fe', + customized_abutment: '#a5b4fc', + prefabricated_abutment: '#93c5fd', + ti_base_abutment: '#bae6fd', + multi_unit_abutment: '#a5b4fc', + zirconia_abutment: '#86efac', + screw_retained: '#c4b5fd', + zirconia_overlay: '#99f6e4', + ips_overlay: '#fde68a', + smile_design: '#f9a8d4', + mockup: '#fbcfe8', +}; + +export const CATALOG_FALLBACK_COLORS = [ + '#ddd6fe', + '#fed7aa', + '#fecaca', + '#bae6fd', + '#d9f99d', + '#fbcfe8', +] as const; + +/** Ordered palette for charts and rotating unknown 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', +]; + +export function resolveCatalogTypeColor( + code: string, + colorMap: Record, + index = 0, +): string { + return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length]; +} diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts index b60f09f..7d6b24d 100644 --- a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts @@ -1,56 +1,21 @@ import type { CSSProperties } from 'react'; +import { + 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 the same hex palette as treatment types. * * 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); } /** 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 index 04ae833..34889a7 100644 --- a/frontend/src/lib/api/today.ts +++ b/frontend/src/lib/api/today.ts @@ -4,6 +4,7 @@ import type { TodaySummaryResponse } from '@/types/today'; export interface TodaySummaryParams { from: string; to: string; + utcOffsetMinutes?: number; } export const todayApi = { diff --git a/frontend/src/lib/hooks/useTodaySummary.ts b/frontend/src/lib/hooks/useTodaySummary.ts index 1231dc2..2fe78ea 100644 --- a/frontend/src/lib/hooks/useTodaySummary.ts +++ b/frontend/src/lib/hooks/useTodaySummary.ts @@ -33,7 +33,8 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary try { const range = getLocalDayIsoRange(new Date()); - const response = await todayApi.summary(range); + const utcOffsetMinutes = -new Date().getTimezoneOffset(); + const response = await todayApi.summary({ ...range, utcOffsetMinutes }); setData(response.data); } catch (err) { setError(err as ApiError); diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index 85df413..67faaf3 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -16,9 +16,26 @@ export type TodayChartBucket = { count: number; }; +export type TodayStackedDayBucket = { + code: string; + label: string; + completed: number; + received: number; +}; + export type TodaySummaryCharts = { treatmentMixWeek?: TodayChartBucket[]; tasksByWorkflowStep?: TodayChartBucket[]; + appointmentsByProvider?: TodayChartBucket[]; + caseCompletion?: { + completed: number; + total: number; + percent: number; + }; + appointmentsWeekAll?: TodayChartBucket[]; + appointmentsWeekMine?: TodayChartBucket[]; + labTaskActivityWeek?: TodayStackedDayBucket[]; + inProgressTasksByProsthesis?: TodayChartBucket[]; }; export type TodayWidgetKey = @@ -28,11 +45,13 @@ export type TodayWidgetKey = | 'draftTreatments' | 'labCasesPendingSend' | 'casesReceivedToday' + | 'casesInProgress' | 'tasksInProgress' | 'importantTasks' | 'pendingConnections' | 'seats' - | 'pendingStaffInvites'; + | 'pendingStaffInvites' + | 'providersWithoutWorkingHours'; export type TodaySummaryWidgets = Partial< Record<