Merge branch 'master' into feature/mobile-responsive

This commit is contained in:
2026-07-12 10:02:58 +03:30
55 changed files with 5194 additions and 780 deletions

View File

@@ -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": {

View File

@@ -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<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ exists: string | null }>>(
`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();
});

View File

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

View File

@@ -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,

View File

@@ -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');
}

View File

@@ -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;
}

View File

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

View File

@@ -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 {}

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
"jsx": "react",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,

View File

@@ -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…",

View File

@@ -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": "در حال انتقال...",

View File

@@ -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...",

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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.');

View File

@@ -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;

View File

@@ -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 (
<div>
<h1 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6">
{t('welcomeBack')}
</h1>
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
{data?.generatedAt && !isInitialLoad ? (
<p className="text-xs text-text-muted">
{t('lastUpdated', {
time: new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(data.generatedAt)),
})}
</p>
) : null}
</div>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
{t('noSubscriptionNotice')}{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
@@ -29,27 +52,28 @@ export default function TodayPage() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card>
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
<p className="text-2xl font-semibold mt-2">12</p>
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
<p className="text-2xl font-semibold mt-2">675</p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
<p className="text-2xl font-semibold mt-2">5</p>
<p className="text-xs text-text-muted mt-1">35 </p>
</Card>
<Card>
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
<p className="text-2xl font-semibold mt-2">1200$</p>
<p className="text-xs text-text-muted mt-1">21,300 $</p>
</Card>
</div>
{error ? (
<TodayLoadErrorBanner
message={formatApiErrorMessage(error, t('loadError'))}
retryLabel={t('retryLoad')}
onRetry={() => void reload()}
isRetrying={loading && Boolean(data)}
/>
) : null}
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayDashboard
widgets={data?.widgets ?? {}}
charts={data?.charts ?? {}}
actions={data?.actions ?? {}}
subscription={data?.subscription}
loading={loading}
isInitialLoad={isInitialLoad}
hasError={Boolean(error)}
/>
</TodayWidgetErrorBoundary>
</div>
);
}

View File

@@ -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 (
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
<TreatmentWorkspace
userId={user.id}
currentOrganization={currentOrganization}
initialAppointmentId={initialAppointmentId}
/>
);
}

View File

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

View File

@@ -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)}`;
}

View File

@@ -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<ChartCardProps, 'title' | 'subtitle'>) {
return (
<div className="mb-3 shrink-0">
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
</div>
);
}
export function ChartCard({
title,
subtitle,
children,
emptyMessage,
isEmpty = false,
loading = false,
sidePanelLayout = false,
chartPanel,
}: ChartCardProps) {
if (loading) {
return <ChartCardSkeleton />;
}
if (sidePanelLayout) {
return (
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
<div className="flex min-h-0 flex-col overflow-hidden">
<ChartCardHeader title={title} subtitle={subtitle} />
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
)}
</div>
{!isEmpty && chartPanel ? (
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
<div className="aspect-square h-full max-h-full w-full max-w-full">
{chartPanel}
</div>
</div>
) : null}
</Card>
);
}
return (
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
<ChartCardHeader title={title} subtitle={subtitle} />
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
)}
</Card>
);
}

View File

@@ -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<KpiCardColor, string> = {
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
purple: '!bg-purpose-consultation-bg !text-purpose-consultation-fg !border-purpose-consultation-border',
default: '',
};
interface KpiCardProps {
title: string;
value: string;
subtitle?: string | null;
icon?: LucideIcon;
color?: KpiCardColor;
loading?: boolean;
href?: string;
className?: string;
}
export function KpiCard({
title,
value,
subtitle,
icon: Icon,
color = 'default',
loading = false,
href,
className = '',
}: KpiCardProps) {
const card = (
<Card
className={`h-full ${colorClasses[color]} ${href && !loading ? 'transition-opacity hover:opacity-90' : ''} ${className}`}
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium">{title}</p>
{Icon ? (
<Icon
className="h-4 w-4 shrink-0 !text-current"
aria-hidden
/>
) : null}
</div>
{loading ? (
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
) : (
<p className="text-2xl font-bold mt-2">{value}</p>
)}
{subtitle ? (
<p className="text-xs opacity-80 mt-1">{subtitle}</p>
) : null}
</Card>
);
if (href && !loading) {
return (
<Link
href={href}
className="block h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
>
{card}
</Link>
);
}
return card;
}

View File

@@ -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 (
<TodayChartFrame>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
{showXAxis ? (
<XAxis
dataKey="label"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={1}
/>
) : (
<XAxis dataKey="label" hide />
)}
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(label) => String(label)}
/>
<Area
type="monotone"
dataKey="count"
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
dot={{ r: 3, fill: color, strokeWidth: 0 }}
activeDot={{ r: 5, fill: color }}
/>
</AreaChart>
</ResponsiveContainer>
</TodayChartFrame>
);
}

View File

@@ -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 (
<TodayChartFrame>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="shortLabel"
tick={
colorForCode
? (props) => {
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 (
<text
x={x}
y={y}
dy={16}
textAnchor="middle"
fill={fill}
fontSize={11}
>
{payload.value}
</text>
);
}
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={{
backgroundColor: TODAY_CHART_TOOLTIP_BG,
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
borderRadius: '6px',
color: '#f5f9ff',
fontSize: '12px',
}}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
return row?.label ?? '';
}}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
{chartData.map((entry, index) => (
<Cell
key={entry.code}
fill={
colorForCode?.(entry.code, index) ??
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TodayChartFrame>
);
}
function truncateLabel(label: string, max = 12): string {
if (label.length <= max) return label;
return `${label.slice(0, max - 1)}`;
}

View File

@@ -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 <div className="h-full min-h-0 w-full flex-1">{children}</div>;
}

View File

@@ -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 (
<Link
href={href}
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
>
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{title}</p>
<p className="mt-0.5 truncate text-xs text-text-muted">{subtitle}</p>
</div>
<Icon className="h-4 w-4 shrink-0 !text-current" aria-hidden />
</div>
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
<div className="w-[58%] min-w-0">
<TodayRadialGaugeChart
size="sm"
compactClassName="h-[120px]"
innerRadius="72%"
compactBarSize={8}
percent={total > 0 ? percent : 0}
completed={completed}
total={total}
percentLabel={total > 0 ? percentLabel : '—'}
tasksLabel={ratioLabel}
fillColor={TODAY_CHART_COMPLETED_COLOR}
showRatio={total > 0}
/>
</div>
</div>
</Card>
</Link>
);
}

View File

@@ -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 (
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
</div>
);
}
return <TodayDashboardGrid cells={cells} loading={loading} />;
}
function buildSkeletonCells(options: {
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
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: <ChartCardSkeleton />,
});
}
}
if (options.showUpcoming) {
cells.push({
id: 'upcoming-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
content: (
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-3">
<div className="mb-2 space-y-1.5">
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
</div>
<div className="space-y-2">
{[0, 1].map((key) => (
<ListRowSkeleton key={key} compact />
))}
</div>
</div>
),
});
}
if (options.showSubscriptionCard) {
cells.push({
id: 'subscription-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
if (options.showCaseCompletionCard) {
cells.push({
id: 'case-completion-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
if (options.showTreatmentPlanCompletionCard) {
cells.push({
id: 'treatment-plan-completion-skeleton',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <KpiCardSkeleton tall />,
});
}
for (const definition of options.kpiDefinitions) {
cells.push({
id: `kpi-skeleton-${definition.key}`,
layout: TODAY_DASHBOARD_LAYOUT.kpi,
content: <KpiCardSkeleton />,
});
}
return cells;
}
function buildDashboardCells(options: {
t: ReturnType<typeof useTranslations<'today'>>;
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
widgets: TodaySummaryWidgets;
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
subscription?: TodaySubscriptionSnapshot;
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
showSubscriptionCard: boolean;
showCaseCompletionCard: boolean;
showTreatmentPlanCompletionCard: boolean;
showUpcoming: boolean;
showCharts: boolean;
orgType?: 'CLINIC' | 'LAB';
isOwner: boolean;
currentOrganization: ReturnType<typeof useAuth>['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: (
<TodayUpcomingAppointments actions={options.actions} loading={false} isInitialLoad={false} />
),
});
}
if (options.showSubscriptionCard && options.subscription) {
cells.push({
id: 'subscription',
layout: TODAY_DASHBOARD_LAYOUT.subscription,
content: <TodaySubscriptionKpiCard subscription={options.subscription} />,
});
}
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: (
<KpiCard
title={options.t(definition.titleKey)}
value={value}
subtitle={subtitle}
icon={definition.icon}
color={definition.color}
href={definition.href}
className="h-full"
/>
),
});
}
return cells;
}
function buildChartCells(options: {
t: ReturnType<typeof useTranslations<'today'>>;
charts: TodaySummaryCharts;
orgType?: 'CLINIC' | 'LAB';
isOwner: boolean;
showMyAppointmentsWeekChart: boolean;
showCasePartnersChart: boolean;
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
}): 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: (
<ChartCard
title={t('chartAppointmentsWeekAllTitle')}
subtitle={t('chartAppointmentsWeekAllSubtitle')}
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekAllData} />
</ChartCard>
),
});
}
if (
orgType === 'CLINIC' &&
showMyAppointmentsWeekChart &&
charts.appointmentsWeekMine !== undefined
) {
cells.push({
id: 'chart-appointments-week-mine',
layout: areaChart,
content: (
<ChartCard
title={t('chartAppointmentsWeekMineTitle')}
subtitle={t('chartAppointmentsWeekMineSubtitle')}
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
<TodayAreaChart data={appointmentsWeekMineData} />
</ChartCard>
),
});
}
if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) {
cells.push({
id: 'chart-lab-task-activity',
layout: areaChart,
content: (
<ChartCard
title={t('chartLabTaskActivityTitle')}
subtitle={t('chartLabTaskActivitySubtitle')}
isEmpty={labTaskActivityData.every(
(row) => row.completed === 0 && row.received === 0,
)}
emptyMessage={t('chartEmpty')}
>
<TodayLabTaskActivityChart
data={labTaskActivityChartData}
completedLabel={t('chartLabTaskCompletedLegend')}
receivedLabel={t('chartLabTaskReceivedLegend')}
/>
</ChartCard>
),
});
}
const efficiencyReportData = charts.efficiencyReport ?? [];
if (
isOwner &&
charts.efficiencyReport !== undefined &&
efficiencyReportData.length >= 2
) {
cells.push({
id: 'chart-efficiency-report',
layout: areaChart,
content: (
<ChartCard
title={t('chartEfficiencyReportTitle')}
subtitle={
orgType === 'CLINIC'
? t('chartEfficiencyReportSubtitleClinic')
: t('chartEfficiencyReportSubtitleLab')
}
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
sidePanelLayout
chartPanel={
<TodayDonutChart
data={efficiencyReportData}
labelForCode={(code) =>
efficiencyReportData.find((row) => row.code === code)?.label ?? code
}
variant="pie"
/>
}
>
<TodayDonutChartLegend
data={efficiencyReportData}
labelForCode={(code) =>
efficiencyReportData.find((row) => row.code === code)?.label ?? code
}
/>
</ChartCard>
),
});
}
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) {
cells.push({
id: 'chart-appointments-by-provider',
layout: barChart,
content: (
<ChartCard
title={t('chartAppointmentsByProviderTitle')}
subtitle={t('chartAppointmentsByProviderSubtitle')}
isEmpty={appointmentsByProviderData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayHorizontalBarChart data={appointmentsByProviderData} />
</ChartCard>
),
});
}
const treatmentData = charts.treatmentMixWeek ?? [];
if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) {
cells.push({
id: 'chart-treatment-mix',
layout: barChart,
content: (
<ChartCard
title={t('chartTreatmentMixTitle')}
subtitle={t('chartTreatmentMixSubtitle')}
isEmpty={treatmentData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart
data={treatmentData}
colorForCode={(code, index) => treatmentTypeColor(code, index)}
/>
</ChartCard>
),
});
}
const tasksByProsthesisData = charts.tasksByProsthesis ?? [];
if (orgType === 'LAB' && charts.tasksByProsthesis !== undefined) {
cells.push({
id: 'chart-tasks-by-prosthesis',
layout: barChart,
content: (
<ChartCard
title={t('chartTasksByProsthesisTitle')}
subtitle={t('chartTasksByProsthesisSubtitle')}
isEmpty={tasksByProsthesisData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart
data={tasksByProsthesisData}
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
/>
</ChartCard>
),
});
}
const casePartnersData = charts.casePartnersMonth ?? [];
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
cells.push({
id: 'chart-case-partners-month',
layout: barChart,
content: (
<ChartCard
title={
orgType === 'CLINIC'
? t('chartCasePartnersClinicTitle')
: t('chartCasePartnersLabTitle')
}
subtitle={t('chartCasePartnersSubtitle')}
isEmpty={casePartnersData.every(
(row) => row.completed === 0 && row.pending === 0,
)}
emptyMessage={t('chartEmpty')}
>
<TodayPartnerCasesStackedBarChart
data={casePartnersData}
completedLabel={t('chartLabTaskCompletedLegend')}
pendingLabel={
orgType === 'CLINIC'
? t('chartCasePartnersSentLegend')
: t('chartCasePartnersOpenLegend')
}
/>
</ChartCard>
),
});
}
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: (
<TodayCompletionGaugeKpiCard
completed={options.gauge.completed}
total={options.gauge.total}
percent={options.gauge.percent}
title={options.title}
subtitle={options.subtitle}
percentLabel={options.percentLabel}
ratioLabel={options.ratioLabel}
href={options.href}
icon={options.icon}
/>
),
});
}

View File

@@ -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 (
<div
className={`${TODAY_DASHBOARD_GRID_CLASS} ${loading ? 'opacity-70 transition-opacity' : ''}`}
style={{ gridAutoRows: 'var(--today-grid-unit, 5.75rem)' }}
>
{packed.map((cell) => (
<div
key={cell.id}
className={`today-dashboard-cell ${packedCellClassName(cell.layout)}`}
style={
{
'--today-gc': cell.gridColumn,
'--today-gr': cell.gridRow,
} as CSSProperties
}
>
<div className="flex h-full min-h-0 flex-1 flex-col">{cell.content}</div>
</div>
))}
</div>
);
}

View File

@@ -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 (
<div className="flex min-w-0 items-start overflow-hidden">
<div className="flex max-h-full min-w-0 flex-col items-start gap-1.5 overflow-y-auto">
{chartData.map((entry, index) => (
<span key={entry.code} className={rowClass}>
<span
className="inline-block h-3 w-3 rounded-sm border"
style={resolveSwatchStyle(entry.code, index)}
aria-hidden
/>
</span>
))}
</div>
<div className="ml-2 flex min-w-0 flex-col items-start gap-1.5 overflow-hidden">
{chartData.map((entry) => (
<span
key={entry.code}
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
>
{entry.displayLabel}
</span>
))}
</div>
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
{chartData.map((entry) => (
<span
key={entry.code}
className={`${rowClass} tabular-nums text-right text-text-muted`}
>
{entry.count}
</span>
))}
</div>
</div>
);
}
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 = (
<ResponsiveContainer width="100%" height="100%">
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={chartData}
dataKey="count"
nameKey="displayLabel"
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={variant === 'pie' ? 1 : 2}
stroke="transparent"
>
{chartData.map((entry, index) => (
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
))}
</Pie>
<Tooltip
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
formatter={(value, _name, item) => {
const row = item?.payload as TodayChartBucket | undefined;
return [value, row ? labelForCode(row.code) : ''];
}}
/>
</PieChart>
</ResponsiveContainer>
);
if (sideLegend) {
return (
<div className="flex h-full min-h-0 w-full items-center gap-3 overflow-hidden sm:gap-4">
<div className="flex min-h-0 min-w-0 flex-1 items-center overflow-hidden">
<TodayDonutChartLegend
data={data}
labelForCode={labelForCode}
colorForCode={colorForCode}
swatchStyleForCode={swatchStyleForCode}
/>
</div>
<div className="aspect-square h-[min(100%,9.5rem)] w-[min(100%,9.5rem)] shrink-0">
{pieChart}
</div>
</div>
);
}
return <TodayChartFrame>{pieChart}</TodayChartFrame>;
}

View File

@@ -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 (
<TodayChartFrame>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 4, right: 12, left: 4, bottom: 4 }}
>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
<XAxis
type="number"
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="shortLabel"
width={96}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
return row?.label ?? '';
}}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
{chartData.map((entry, index) => (
<Cell
key={entry.code}
fill={chartRankColor(index)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TodayChartFrame>
);
}
function truncateLabel(label: string, max = 18): string {
if (label.length <= max) return label;
return `${label.slice(0, max - 1)}`;
}

View File

@@ -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 (
<TodayChartFrame>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
<stop offset="100%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.05} />
</linearGradient>
<linearGradient id="labTaskReceivedFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.4} />
<stop offset="100%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={1}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(label) => String(label)}
/>
<Area
type="monotone"
dataKey="completed"
name={completedLabel}
stroke={TODAY_CHART_COMPLETED_COLOR}
strokeWidth={2}
fill="url(#labTaskCompletedFill)"
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
/>
<Area
type="monotone"
dataKey="received"
name={receivedLabel}
stroke={TODAY_CHART_RECEIVED_COLOR}
strokeWidth={2}
fill="url(#labTaskReceivedFill)"
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
aria-hidden
/>
{completedLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
aria-hidden
/>
{receivedLabel}
</span>
</div>
</div>
</TodayChartFrame>
);
}
export function mapLabTaskActivityChartData(
buckets: TodayStackedDayBucket[],
): LabTaskActivityChartRow[] {
return buckets.map((bucket) => ({
label: bucket.label,
completed: bucket.completed,
received: bucket.received,
}));
}

View File

@@ -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 (
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p className="text-sm text-badge-danger-fg">{message}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRetry}
isLoading={isRetrying}
className="shrink-0 border-badge-danger-border text-badge-danger-fg hover:bg-badge-danger-bg/30"
>
{retryLabel}
</Button>
</div>
);
}

View File

@@ -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 (
<TodayChartFrame>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="shortLabel"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined;
return row?.label ?? '';
}}
/>
<Bar
dataKey="completed"
name={completedLabel}
stackId="cases"
fill={TODAY_CHART_COMPLETED_COLOR}
radius={[0, 0, 0, 0]}
maxBarSize={48}
/>
<Bar
dataKey="pending"
name={pendingLabel}
stackId="cases"
fill={TODAY_CHART_RECEIVED_COLOR}
radius={[4, 4, 0, 0]}
maxBarSize={48}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
aria-hidden
/>
{completedLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
aria-hidden
/>
{pendingLabel}
</span>
</div>
</div>
</TodayChartFrame>
);
}
function truncateLabel(label: string, max = 12): string {
if (label.length <= max) return label;
return `${label.slice(0, max - 1)}`;
}

View File

@@ -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 (
<div className={`relative w-full ${wrapperClass}`}>
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius={resolvedInnerRadius}
outerRadius="100%"
barSize={resolvedBarSize}
data={data}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
dataKey="value"
cornerRadius={isCompact ? 6 : 8}
/>
</RadialBarChart>
</ResponsiveContainer>
<div
className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center ${
innerRadius != null && isCompact ? 'px-2.5' : 'px-1'
}`}
>
<span
className={`font-semibold text-text-primary ${isCompact ? 'text-base leading-tight' : 'text-3xl'}`}
>
{percentLabel}
</span>
<span className={`text-text-muted ${isCompact ? 'mt-0.5 text-[10px]' : 'mt-1 text-xs'}`}>
{tasksLabel}
</span>
{showRatio && total > 0 ? (
<span
className={`text-text-secondary ${isCompact ? 'mt-0.5 text-[10px]' : 'mt-0.5 text-[11px]'}`}
>
{completed}/{total}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Card } from '@/components/ui/shared/Card';
interface TodaySectionErrorFallbackProps {
message: string;
}
export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) {
return (
<Card className="min-h-[120px] flex items-center justify-center border-badge-danger-border/40 bg-badge-danger-bg/20">
<p className="text-sm text-badge-danger-fg text-center px-4">{message}</p>
</Card>
);
}

View File

@@ -0,0 +1,38 @@
interface SkeletonBlockProps {
className?: string;
}
export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
return (
<div
className={`animate-pulse rounded-[var(--radius-md)] bg-background-secondary/60 ${className}`}
aria-hidden
/>
);
}
export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) {
return (
<div
className={`flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-4 ${tall ? '' : ''}`}
>
<SkeletonBlock className="h-4 w-2/3" />
<SkeletonBlock className={`${tall ? 'mt-4 flex-1' : 'h-8 w-16 mt-3'}`} />
{!tall ? <SkeletonBlock className="h-3 w-1/3 mt-2" /> : null}
</div>
);
}
export function ChartCardSkeleton() {
return (
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-4">
<SkeletonBlock className="h-4 w-1/3" />
<SkeletonBlock className="h-3 w-1/4 mt-2" />
<SkeletonBlock className="min-h-0 flex-1 mt-4" />
</div>
);
}
export function ListRowSkeleton({ compact = false }: { compact?: boolean }) {
return <SkeletonBlock className={`w-full ${compact ? 'h-8' : 'h-12'}`} />;
}

View File

@@ -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 (
<Link
href="/settings/subscriptions"
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
>
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t('widgetSubscription')}</p>
{subscription.planName ? (
<p className="mt-0.5 truncate text-xs capitalize text-text-muted">
{subscription.planName}
</p>
) : (
<p className="mt-0.5 text-xs text-text-muted">{t('subscriptionNoPlan')}</p>
)}
</div>
<CreditCard className="h-4 w-4 shrink-0 !text-current" aria-hidden />
</div>
<div className="mt-2 grid min-h-0 flex-1 grid-cols-2 gap-1 content-center">
<TodayRadialGaugeChart
size="sm"
percent={
subscription.seatsUnlimited || !subscription.hasActivePlan
? 0
: subscription.seatsPercent
}
completed={subscription.seatsUsed}
total={seatsRatioTotal}
percentLabel={seatsPercentLabel}
tasksLabel={seatsTasksLabel}
showRatio={!subscription.seatsUnlimited && seatsRatioTotal > 0}
/>
<TodayRadialGaugeChart
size="sm"
percent={subscription.hasActivePlan ? subscription.periodPercent : 0}
completed={subscription.periodElapsedDays}
total={subscription.hasActivePlan ? subscription.periodTotalDays : 0}
percentLabel={periodPercentLabel}
tasksLabel={t('subscriptionPeriodLabel')}
fillColor={PERIOD_GAUGE_COLOR}
showRatio={subscription.hasActivePlan}
/>
</div>
</Card>
</Link>
);
}

View File

@@ -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<TreatmentCatalogEntry[]>([]);
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 (
<Card className="flex h-full min-h-0 flex-col p-3">
<div className="mb-2 space-y-1.5">
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
</div>
<div className="space-y-2">
{[0, 1].map((key) => (
<ListRowSkeleton key={key} compact />
))}
</div>
</Card>
);
}
return (
<Card className="flex h-full min-h-0 flex-col p-3">
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-sm font-semibold text-card-foreground">
{t('upcomingAppointmentsTitle')}
</h2>
<p className="mt-0.5 text-[11px] text-text-muted">{t('upcomingAppointmentsSubtitle')}</p>
</div>
<Link
href={treatmentAppointmentHref()}
className="shrink-0 text-[11px] font-medium text-primary hover:underline underline-offset-2"
>
{t('viewAllAppointments')}
</Link>
</div>
{appointments.length === 0 ? (
<div className="flex min-h-[72px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-3">
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
</div>
) : (
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<ul className="divide-y divide-border/40">
{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 (
<li key={appointment.id}>
<Link
href={treatmentAppointmentHref(appointment.id)}
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
>
<div className="min-w-0">
<p className="truncate text-xs font-medium text-text-primary">
{appointment.patientName}
</p>
<p className="mt-0.5 truncate text-[11px] text-text-muted">
{timeLabel}
{appointment.purpose ? (
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
) : null}
</p>
</div>
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
/>
</Link>
</li>
);
})}
</ul>
</div>
)}
</Card>
);
}

View File

@@ -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;
}
}

View File

@@ -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<T extends { code: string; label: string }>(
buckets: T[],
formatter: Intl.DateTimeFormat,
): T[] {
return buckets.map((bucket) => ({
...bucket,
label: formatTodayChartDayLabel(bucket.code, formatter),
}));
}

View File

@@ -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<string>(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;

View File

@@ -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<string, TodayDashboardLayout>;
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<T extends { layout: TodayDashboardLayout; id: string }>(
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 (12 or 34 on a 4-col grid).
*/
export function packDashboardCells(
cells: TodayDashboardCell[],
columns = 4,
): PackedDashboardCell[] {
const sorted = sortDashboardCells(cells);
const occupied = new Set<string>();
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';

View File

@@ -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<TodayGadgetFeature, number> = {
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<TodayWidgetKey, TodayGadgetFeature> = {
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<string, TodayGadgetFeature> = {
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;
}

View File

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

View File

@@ -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 &&

View File

@@ -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);

View File

@@ -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<string | null>(initialAppointmentId);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
if (initialAppointmentId) {
setSelectedDay(startOfLocalDay(new Date()));
setSelectionLocked(false);
}
}, [initialAppointmentId]);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(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());

View File

@@ -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<string, string> = {
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<string, string> = {
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<string, string>,
index = 0,
fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS,
): string {
return colorMap[code] ?? fallbackColors[index % fallbackColors.length];
}

View File

@@ -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<string, string> = {
// 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). */

View File

@@ -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<string, string> = {
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). */

View File

@@ -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<TodaySummaryResponse> => {
const response = await apiClient.get('/today/summary', { params });
return response.data;
},
};

View File

@@ -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<void>;
}
export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult {
const enabled = Boolean(organizationId);
const [data, setData] = useState<TodaySummaryData | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<ApiError | null>(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,
};
}

View File

@@ -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));

100
frontend/src/types/today.ts Normal file
View File

@@ -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;
}