Positioning, sizing and sorting of the gadgets improved.
This commit is contained in:
@@ -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": {
|
||||
|
||||
112
backend/prisma/wipe-app-data.ts
Normal file
112
backend/prisma/wipe-app-data.ts
Normal 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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ type TodayCharts = {
|
||||
appointmentsWeekMine?: ChartBucket[];
|
||||
labTaskActivityWeek?: StackedDayBucket[];
|
||||
inProgressTasksByProsthesis?: ChartBucket[];
|
||||
efficiencyReport?: ChartBucket[];
|
||||
};
|
||||
|
||||
type TodayActions = {
|
||||
@@ -57,6 +58,20 @@ type TodayActions = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type TodaySubscriptionWidget = {
|
||||
hasActivePlan: boolean;
|
||||
planName: string | null;
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | null;
|
||||
seatsUnlimited: boolean;
|
||||
seatsPercent: number;
|
||||
periodStartAt: string;
|
||||
periodEndAt: string | null;
|
||||
periodTotalDays: number;
|
||||
periodElapsedDays: number;
|
||||
periodPercent: number;
|
||||
};
|
||||
|
||||
type TodayWidgets = {
|
||||
appointmentsToday?: { count: number };
|
||||
patientsToday?: { count: number };
|
||||
@@ -68,7 +83,6 @@ type TodayWidgets = {
|
||||
tasksInProgress?: { count: number };
|
||||
importantTasks?: { count: number };
|
||||
pendingConnections?: { count: number };
|
||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
||||
pendingStaffInvites?: { count: number };
|
||||
providersWithoutWorkingHours?: { count: number };
|
||||
};
|
||||
@@ -106,6 +120,7 @@ export class TodayService {
|
||||
const charts: TodayCharts = {};
|
||||
const actions: TodayActions = {};
|
||||
const tasks: Promise<void>[] = [];
|
||||
let subscription: TodaySubscriptionWidget | undefined;
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
||||
@@ -210,8 +225,23 @@ export class TodayService {
|
||||
);
|
||||
}
|
||||
|
||||
if (membership.isOwner) {
|
||||
if (orgType === 'CLINIC') {
|
||||
tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts));
|
||||
}
|
||||
if (orgType === 'LAB') {
|
||||
tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts));
|
||||
}
|
||||
tasks.push(
|
||||
this.buildSubscriptionWidget(organizationId, membership.organization).then(
|
||||
(value) => {
|
||||
subscription = value;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
|
||||
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
||||
}
|
||||
|
||||
@@ -226,6 +256,7 @@ export class TodayService {
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
...(subscription ? { subscription } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,7 +387,7 @@ export class TodayService {
|
||||
patient: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
take: 3,
|
||||
take: 10,
|
||||
});
|
||||
|
||||
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
||||
@@ -481,25 +512,197 @@ export class TodayService {
|
||||
widgets.pendingConnections = { count };
|
||||
}
|
||||
|
||||
private async loadSeats(
|
||||
private async getActiveEditAccessUserIds(
|
||||
organizationId: string,
|
||||
plan: { maxUsers: number } | null,
|
||||
widgets: TodayWidgets,
|
||||
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
|
||||
): Promise<string[]> {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ isOwner: true },
|
||||
{
|
||||
isOwner: false,
|
||||
permissions: {
|
||||
some: { permission: { name: editPermission } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return members.map((member) => member.userId);
|
||||
}
|
||||
|
||||
private async loadClinicEfficiencyReport(
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const used = await this.prisma.membership.count({
|
||||
const eligibleUserIds = await this.getActiveEditAccessUserIds(
|
||||
organizationId,
|
||||
'TAB_TREATMENT_EDIT',
|
||||
);
|
||||
if (eligibleUserIds.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||
const grouped = await this.prisma.treatment.groupBy({
|
||||
by: ['providerUserId'],
|
||||
where: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: monthStart, lt: rangeEnd },
|
||||
providerUserId: { in: eligibleUserIds },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const countsByUser = new Map(
|
||||
eligibleUserIds.map((userId) => [userId, 0]),
|
||||
);
|
||||
for (const row of grouped) {
|
||||
countsByUser.set(row.providerUserId, aggregateCount(row._count));
|
||||
}
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: eligibleUserIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
||||
|
||||
charts.efficiencyReport = eligibleUserIds
|
||||
.map((userId) => ({
|
||||
code: userId,
|
||||
label: nameById.get(userId) ?? userId,
|
||||
count: countsByUser.get(userId) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private async loadLabEfficiencyReport(
|
||||
labOrganizationId: string,
|
||||
rangeEnd: Date,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const eligibleUserIds = await this.getActiveEditAccessUserIds(
|
||||
labOrganizationId,
|
||||
'TAB_TASKS_EDIT',
|
||||
);
|
||||
if (eligibleUserIds.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
|
||||
by: ['changedByUserId'],
|
||||
where: {
|
||||
toStatus: LabTaskStatus.COMPLETED,
|
||||
changedAt: { gte: monthStart, lt: rangeEnd },
|
||||
changedByUserId: { in: eligibleUserIds },
|
||||
task: {
|
||||
labCase: {
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const countsByUser = new Map(
|
||||
eligibleUserIds.map((userId) => [userId, 0]),
|
||||
);
|
||||
for (const row of grouped) {
|
||||
if (!row.changedByUserId) continue;
|
||||
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
|
||||
}
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: eligibleUserIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
||||
|
||||
charts.efficiencyReport = eligibleUserIds
|
||||
.map((userId) => ({
|
||||
code: userId,
|
||||
label: nameById.get(userId) ?? userId,
|
||||
count: countsByUser.get(userId) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private async buildSubscriptionWidget(
|
||||
organizationId: string,
|
||||
organization: {
|
||||
createdAt: Date;
|
||||
plan: { name: string; maxUsers: number } | null;
|
||||
},
|
||||
): Promise<TodaySubscriptionWidget> {
|
||||
const seatsUsed = await this.prisma.membership.count({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const maxUsers = plan?.maxUsers ?? 0;
|
||||
const unlimited = isUnlimitedSeats(maxUsers);
|
||||
const plan = organization.plan;
|
||||
const periodStartAt = organization.createdAt.toISOString();
|
||||
|
||||
widgets.seats = {
|
||||
used,
|
||||
limit: unlimited ? null : maxUsers,
|
||||
unlimited,
|
||||
if (!plan) {
|
||||
return {
|
||||
hasActivePlan: false,
|
||||
planName: null,
|
||||
seatsUsed,
|
||||
seatsLimit: null,
|
||||
seatsUnlimited: false,
|
||||
seatsPercent: 0,
|
||||
periodStartAt,
|
||||
periodEndAt: null,
|
||||
periodTotalDays: 0,
|
||||
periodElapsedDays: 0,
|
||||
periodPercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const maxUsers = plan.maxUsers;
|
||||
const seatsUnlimited = isUnlimitedSeats(maxUsers);
|
||||
const seatsLimit = seatsUnlimited ? null : maxUsers;
|
||||
const seatsPercent =
|
||||
seatsUnlimited || maxUsers <= 0
|
||||
? 0
|
||||
: Math.min(100, Math.round((seatsUsed / maxUsers) * 100));
|
||||
|
||||
const durationDays = plan.name === 'trial' ? 30 : 90;
|
||||
const periodEnd = new Date(organization.createdAt);
|
||||
periodEnd.setDate(periodEnd.getDate() + durationDays);
|
||||
const periodEndAt = periodEnd.toISOString();
|
||||
const totalMs = periodEnd.getTime() - organization.createdAt.getTime();
|
||||
const elapsedMs = Math.min(
|
||||
Math.max(0, Date.now() - organization.createdAt.getTime()),
|
||||
totalMs,
|
||||
);
|
||||
const periodPercent =
|
||||
totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0;
|
||||
const periodElapsedDays = Math.min(
|
||||
durationDays,
|
||||
Math.floor(elapsedMs / 86_400_000),
|
||||
);
|
||||
|
||||
return {
|
||||
hasActivePlan: true,
|
||||
planName: plan.name,
|
||||
seatsUsed,
|
||||
seatsLimit,
|
||||
seatsUnlimited,
|
||||
seatsPercent,
|
||||
periodStartAt,
|
||||
periodEndAt,
|
||||
periodTotalDays: durationDays,
|
||||
periodElapsedDays,
|
||||
periodPercent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,12 +1088,7 @@ export class TodayService {
|
||||
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
[
|
||||
'TAB_APPOINTMENTS_READ',
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
].includes(p),
|
||||
['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"jsx": "react",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -208,8 +208,17 @@
|
||||
"widgetImportantTasks": "Important Tasks",
|
||||
"widgetPendingConnections": "Pending Connections",
|
||||
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||
"widgetSeats": "Seat Usage",
|
||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
||||
"widgetSubscription": "Subscription",
|
||||
"subscriptionSeatsLabel": "Seats used",
|
||||
"subscriptionSeatsRemainingLabel": "Seats left",
|
||||
"subscriptionSeatsPercent": "{percent}%",
|
||||
"subscriptionSeatsUnlimitedShort": "Unlimited",
|
||||
"subscriptionPeriodLabel": "Plan period",
|
||||
"subscriptionPeriodRemainingLabel": "Days left",
|
||||
"subscriptionPeriodPercent": "{percent}%",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} days",
|
||||
"subscriptionNoPlan": "No active plan",
|
||||
"chartAppointmentsWeekAllTitle": "Appointments This Week",
|
||||
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
||||
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
||||
@@ -230,6 +239,9 @@
|
||||
"chartCaseCompletionTasks": "Tasks completed",
|
||||
"chartTasksByStepTitle": "Tasks by Workflow Step",
|
||||
"chartTasksByStepSubtitle": "In progress now",
|
||||
"chartEfficiencyReportTitle": "Efficiency Report",
|
||||
"chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days",
|
||||
"chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days",
|
||||
"chartEmpty": "No data for this period yet.",
|
||||
"upcomingAppointmentsTitle": "Upcoming Today",
|
||||
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
|
||||
|
||||
@@ -208,8 +208,17 @@
|
||||
"widgetImportantTasks": "وظایف مهم",
|
||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||
"widgetSeats": "استفاده از صندلی",
|
||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||
"widgetSubscription": "اشتراک",
|
||||
"subscriptionSeatsLabel": "صندلیهای استفادهشده",
|
||||
"subscriptionSeatsRemainingLabel": "صندلی باقیمانده",
|
||||
"subscriptionSeatsPercent": "{percent}٪",
|
||||
"subscriptionSeatsUnlimitedShort": "نامحدود",
|
||||
"subscriptionPeriodLabel": "دوره اشتراک",
|
||||
"subscriptionPeriodRemainingLabel": "روز باقیمانده",
|
||||
"subscriptionPeriodPercent": "{percent}٪",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} روز",
|
||||
"subscriptionNoPlan": "اشتراک فعال نیست",
|
||||
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
||||
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
||||
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
||||
@@ -230,6 +239,9 @@
|
||||
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
|
||||
"chartTasksByStepSubtitle": "در حال انجام",
|
||||
"chartEfficiencyReportTitle": "گزارش کارایی",
|
||||
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
||||
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
||||
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
||||
|
||||
@@ -208,8 +208,17 @@
|
||||
"widgetImportantTasks": "Belangrijke taken",
|
||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||
"widgetSeats": "Zitplaatsgebruik",
|
||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
||||
"widgetSubscription": "Abonnement",
|
||||
"subscriptionSeatsLabel": "Gebruikte zitplaatsen",
|
||||
"subscriptionSeatsRemainingLabel": "Zitplaatsen over",
|
||||
"subscriptionSeatsPercent": "{percent}%",
|
||||
"subscriptionSeatsUnlimitedShort": "Onbeperkt",
|
||||
"subscriptionPeriodLabel": "Abonnementsperiode",
|
||||
"subscriptionPeriodRemainingLabel": "Dagen over",
|
||||
"subscriptionPeriodPercent": "{percent}%",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} dagen",
|
||||
"subscriptionNoPlan": "Geen actief abonnement",
|
||||
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
|
||||
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
||||
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
||||
@@ -230,6 +239,9 @@
|
||||
"chartCaseCompletionTasks": "Taken voltooid",
|
||||
"chartTasksByStepTitle": "Taken per workflowstap",
|
||||
"chartTasksByStepSubtitle": "Nu in uitvoering",
|
||||
"chartEfficiencyReportTitle": "Efficiëntierapport",
|
||||
"chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen",
|
||||
"chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen",
|
||||
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
||||
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
|
||||
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,18 +4,8 @@ import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewLabCasesOrTasks,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
} from '@/components/shared/permissions';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
|
||||
import { TodayChartsSection } from '@/components/today/TodayChartsSection';
|
||||
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
||||
import { TodayDashboard } from '@/components/today/TodayDashboard';
|
||||
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
||||
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
||||
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
|
||||
@@ -27,29 +17,10 @@ export default function TodayPage() {
|
||||
const orgId = currentOrganization?.id;
|
||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||
|
||||
const showNoSubscriptionNotice =
|
||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||
|
||||
const showUpcoming =
|
||||
currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization);
|
||||
const showCharts = useMemo(() => {
|
||||
const orgType = currentOrganization?.type;
|
||||
if (!orgType) return false;
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
return (
|
||||
canAccessAppointmentsSection(currentOrganization) ||
|
||||
canViewAppointmentsTab(currentOrganization) ||
|
||||
canViewTreatment(currentOrganization)
|
||||
const showNoSubscriptionNotice = useMemo(
|
||||
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
|
||||
[currentOrganization],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
canViewCases(currentOrganization) ||
|
||||
canViewTasks(currentOrganization) ||
|
||||
canViewLabCasesOrTasks(currentOrganization)
|
||||
);
|
||||
}, [currentOrganization]);
|
||||
|
||||
const sectionErrorMessage = t('sectionLoadError');
|
||||
|
||||
@@ -93,47 +64,16 @@ export default function TodayPage() {
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<TodayKpiGrid
|
||||
<TodayDashboard
|
||||
widgets={data?.widgets ?? {}}
|
||||
charts={data?.charts ?? {}}
|
||||
actions={data?.actions ?? {}}
|
||||
subscription={data?.subscription}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
</TodayWidgetErrorBoundary>
|
||||
|
||||
{showUpcoming && (!error || data) ? (
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||
>
|
||||
<TodayUpcomingAppointments
|
||||
actions={data?.actions ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
/>
|
||||
{showCharts ? (
|
||||
<TodayChartsSection
|
||||
charts={data?.charts ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
embedded
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TodayWidgetErrorBoundary>
|
||||
) : showCharts && (!error || data) ? (
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<TodayChartsSection
|
||||
charts={data?.charts ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
/>
|
||||
</TodayWidgetErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -24,8 +24,8 @@ export function ChartCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="min-h-[280px] flex flex-col">
|
||||
<div className="mb-4">
|
||||
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="mb-4 shrink-0">
|
||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||
{subtitle ? (
|
||||
<p className="text-xs text-text-muted mt-1">{subtitle}</p>
|
||||
@@ -33,11 +33,11 @@ export function ChartCard({
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex-1 min-h-[220px] flex items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
|
||||
<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="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-[220px] flex-col">{children}</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import type { KpiCardColor } from '@/components/today/widget-registry';
|
||||
@@ -20,6 +22,7 @@ interface KpiCardProps {
|
||||
color?: KpiCardColor;
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
@@ -30,10 +33,11 @@ export function KpiCard({
|
||||
color = 'default',
|
||||
loading = false,
|
||||
href,
|
||||
className = '',
|
||||
}: KpiCardProps) {
|
||||
const card = (
|
||||
<Card
|
||||
className={`${colorClasses[color]} ${href && !loading ? 'transition-opacity hover:opacity-90' : ''}`}
|
||||
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>
|
||||
@@ -57,7 +61,10 @@ export function KpiCard({
|
||||
|
||||
if (href && !loading) {
|
||||
return (
|
||||
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
@@ -23,7 +24,8 @@ interface TodayAreaChartProps {
|
||||
|
||||
export function TodayAreaChart({ data }: TodayAreaChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="todayAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
@@ -62,5 +64,6 @@ export function TodayAreaChart({ data }: TodayAreaChartProps) {
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COLORS,
|
||||
@@ -31,7 +32,8 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
||||
@@ -106,6 +108,7 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
59
frontend/src/components/today/TodayCaseCompletionKpiCard.tsx
Normal file
59
frontend/src/components/today/TodayCaseCompletionKpiCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Package } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme';
|
||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||
|
||||
interface TodayCaseCompletionKpiCardProps {
|
||||
completed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function TodayCaseCompletionKpiCard({
|
||||
completed,
|
||||
total,
|
||||
percent,
|
||||
}: TodayCaseCompletionKpiCardProps) {
|
||||
const t = useTranslations('today');
|
||||
|
||||
const percentLabel =
|
||||
total > 0 ? t('chartCaseCompletionPercent', { percent }) : '—';
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/cases"
|
||||
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('chartCaseCompletionTitle')}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-text-muted">
|
||||
{t('chartCaseCompletionSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Package 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-1/2 min-w-0">
|
||||
<TodayRadialGaugeChart
|
||||
size="sm"
|
||||
percent={total > 0 ? percent : 0}
|
||||
completed={completed}
|
||||
total={total}
|
||||
percentLabel={percentLabel}
|
||||
tasksLabel={t('chartCaseCompletionTasks')}
|
||||
fillColor={TODAY_CHART_COMPLETED_COLOR}
|
||||
showRatio={total > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/today/TodayChartFrame.tsx
Normal file
8
frontend/src/components/today/TodayChartFrame.tsx
Normal 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>;
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { ChartCard } from '@/components/today/ChartCard';
|
||||
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||
import {
|
||||
formatTodayChartDayLabel,
|
||||
mapWeekChartBuckets,
|
||||
useTodayDayLabelFormatter,
|
||||
} from '@/components/today/chart-day-labels';
|
||||
import { TodayDonutChart } from '@/components/today/TodayDonutChart';
|
||||
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||
import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart';
|
||||
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type { TodaySummaryCharts } from '@/types/today';
|
||||
|
||||
interface TodayChartsSectionProps {
|
||||
charts: TodaySummaryCharts;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
className?: string;
|
||||
/** When true, chart cards render as siblings (no outer grid wrapper). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TodayChartsSection({
|
||||
charts,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
className = '',
|
||||
embedded = false,
|
||||
}: TodayChartsSectionProps) {
|
||||
const t = useTranslations('today');
|
||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgType = currentOrganization?.type;
|
||||
|
||||
const showAppointmentsByProvider =
|
||||
orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined;
|
||||
const showAppointmentsWeekAll =
|
||||
orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined;
|
||||
const showAppointmentsWeekMine =
|
||||
orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined;
|
||||
const showTreatmentMix =
|
||||
orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined;
|
||||
const showCaseCompletion =
|
||||
orgType === 'LAB' && charts.caseCompletion !== undefined;
|
||||
const showTasksByStep =
|
||||
orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined;
|
||||
const showLabTaskActivityWeek =
|
||||
orgType === 'LAB' && charts.labTaskActivityWeek !== undefined;
|
||||
const showInProgressTasksByProsthesis =
|
||||
orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined;
|
||||
|
||||
const visibleChartCount =
|
||||
Number(showAppointmentsByProvider) +
|
||||
Number(showAppointmentsWeekAll) +
|
||||
Number(showAppointmentsWeekMine) +
|
||||
Number(showTreatmentMix) +
|
||||
Number(showCaseCompletion) +
|
||||
Number(showTasksByStep) +
|
||||
Number(showLabTaskActivityWeek) +
|
||||
Number(showInProgressTasksByProsthesis);
|
||||
|
||||
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||
const appointmentsWeekAllData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter),
|
||||
[charts.appointmentsWeekAll, dayLabelFormatter],
|
||||
);
|
||||
const appointmentsWeekMineData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter),
|
||||
[charts.appointmentsWeekMine, dayLabelFormatter],
|
||||
);
|
||||
const treatmentData = charts.treatmentMixWeek ?? [];
|
||||
const tasksData = charts.tasksByWorkflowStep ?? [];
|
||||
const labTaskActivityData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter),
|
||||
[charts.labTaskActivityWeek, dayLabelFormatter],
|
||||
);
|
||||
const prosthesisData = charts.inProgressTasksByProsthesis ?? [];
|
||||
const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 };
|
||||
|
||||
const formatDayLabel = (code: string) =>
|
||||
formatTodayChartDayLabel(code, dayLabelFormatter);
|
||||
|
||||
if (visibleChartCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => (
|
||||
<ChartCardSkeleton key={index} />
|
||||
));
|
||||
|
||||
if (embedded) {
|
||||
return <>{skeletons}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${className}`}>{skeletons}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridClass =
|
||||
visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1';
|
||||
|
||||
const chartCards = (
|
||||
<>
|
||||
{showAppointmentsWeekAll ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showAppointmentsWeekMine ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekMineTitle')}
|
||||
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekMineData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showLabTaskActivityWeek ? (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayStackedBarChart
|
||||
data={labTaskActivityData}
|
||||
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||
formatDayLabel={formatDayLabel}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showInProgressTasksByProsthesis ? (
|
||||
<ChartCard
|
||||
title={t('chartProsthesisMixTitle')}
|
||||
subtitle={t('chartProsthesisMixSubtitle')}
|
||||
isEmpty={prosthesisData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayDonutChart
|
||||
data={prosthesisData}
|
||||
labelForCode={(code) =>
|
||||
prosthesisData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||
swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)}
|
||||
variant="pie"
|
||||
sideLegend
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showAppointmentsByProvider ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsByProviderTitle')}
|
||||
subtitle={t('chartAppointmentsByProviderSubtitle')}
|
||||
isEmpty={appointmentsByProviderData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayHorizontalBarChart data={appointmentsByProviderData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showTreatmentMix ? (
|
||||
<ChartCard
|
||||
title={t('chartTreatmentMixTitle')}
|
||||
subtitle={t('chartTreatmentMixSubtitle')}
|
||||
isEmpty={treatmentData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart
|
||||
data={treatmentData}
|
||||
colorForCode={(code, index) => treatmentTypeColor(code, index)}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showCaseCompletion ? (
|
||||
<ChartCard
|
||||
title={t('chartCaseCompletionTitle')}
|
||||
subtitle={t('chartCaseCompletionSubtitle')}
|
||||
isEmpty={caseCompletion.total === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayRadialGaugeChart
|
||||
percent={caseCompletion.percent}
|
||||
completed={caseCompletion.completed}
|
||||
total={caseCompletion.total}
|
||||
percentLabel={t('chartCaseCompletionPercent', {
|
||||
percent: caseCompletion.percent,
|
||||
})}
|
||||
tasksLabel={t('chartCaseCompletionTasks')}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showTasksByStep ? (
|
||||
<ChartCard
|
||||
title={t('chartTasksByStepTitle')}
|
||||
subtitle={t('chartTasksByStepSubtitle')}
|
||||
isEmpty={tasksData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart data={tasksData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return chartCards;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${gridClass} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
|
||||
>
|
||||
{chartCards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
555
frontend/src/components/today/TodayDashboard.tsx
Normal file
555
frontend/src/components/today/TodayDashboard.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import {
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewLabCasesOrTasks,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
} from '@/components/shared/permissions';
|
||||
import { KpiCard } from '@/components/today/KpiCard';
|
||||
import { ChartCard } from '@/components/today/ChartCard';
|
||||
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||
import {
|
||||
formatTodayChartDayLabel,
|
||||
mapWeekChartBuckets,
|
||||
useTodayDayLabelFormatter,
|
||||
} from '@/components/today/chart-day-labels';
|
||||
import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid';
|
||||
import { TodayDonutChart } from '@/components/today/TodayDonutChart';
|
||||
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||
import { TodayCaseCompletionKpiCard } from '@/components/today/TodayCaseCompletionKpiCard';
|
||||
import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart';
|
||||
import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard';
|
||||
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
||||
import {
|
||||
ChartCardSkeleton,
|
||||
KpiCardSkeleton,
|
||||
ListRowSkeleton,
|
||||
} from '@/components/today/TodaySkeleton';
|
||||
import {
|
||||
TODAY_DASHBOARD_LAYOUT,
|
||||
type TodayDashboardCell,
|
||||
} from '@/components/today/today-dashboard-layout';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type {
|
||||
TodaySubscriptionSnapshot,
|
||||
TodaySummaryActions,
|
||||
TodaySummaryCharts,
|
||||
TodaySummaryWidgets,
|
||||
} from '@/types/today';
|
||||
|
||||
interface TodayDashboardProps {
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function TodayDashboard({
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
}: TodayDashboardProps) {
|
||||
const t = useTranslations('today');
|
||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgType = currentOrganization?.type;
|
||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||
|
||||
const showUpcoming =
|
||||
orgType === 'CLINIC' && currentOrganization && canViewTreatment(currentOrganization);
|
||||
|
||||
const showCharts = useMemo(() => {
|
||||
if (!orgType || !currentOrganization) return false;
|
||||
if (orgType === 'CLINIC') {
|
||||
return (
|
||||
canViewAppointmentsTab(currentOrganization) ||
|
||||
canViewTreatment(currentOrganization)
|
||||
);
|
||||
}
|
||||
return (
|
||||
canViewCases(currentOrganization) ||
|
||||
canViewTasks(currentOrganization) ||
|
||||
canViewLabCasesOrTasks(currentOrganization)
|
||||
);
|
||||
}, [currentOrganization, orgType]);
|
||||
|
||||
const kpiDefinitions = isInitialLoad
|
||||
? getEligibleTodayKpis(currentOrganization)
|
||||
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||
|
||||
const showSubscriptionCard = isOwner && (isInitialLoad || Boolean(subscription));
|
||||
|
||||
const showCaseCompletionCard =
|
||||
orgType === 'LAB' &&
|
||||
Boolean(currentOrganization && canViewCases(currentOrganization)) &&
|
||||
(isInitialLoad || charts.caseCompletion !== undefined);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
if (isInitialLoad) {
|
||||
return buildSkeletonCells({
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard,
|
||||
showCaseCompletionCard,
|
||||
showUpcoming: Boolean(showUpcoming),
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
charts,
|
||||
});
|
||||
}
|
||||
|
||||
return buildDashboardCells({
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||
showCaseCompletionCard:
|
||||
showCaseCompletionCard && charts.caseCompletion !== undefined,
|
||||
showUpcoming: Boolean(showUpcoming),
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
currentOrganization,
|
||||
});
|
||||
}, [
|
||||
isInitialLoad,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard,
|
||||
showCaseCompletionCard,
|
||||
showUpcoming,
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
charts,
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
actions,
|
||||
subscription,
|
||||
currentOrganization,
|
||||
]);
|
||||
|
||||
if (hasError && !loading && cells.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!loading && !hasError && cells.length === 0) {
|
||||
return (
|
||||
<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;
|
||||
showUpcoming: boolean;
|
||||
showCharts: boolean;
|
||||
orgType?: 'CLINIC' | 'LAB';
|
||||
isOwner: boolean;
|
||||
charts: TodaySummaryCharts;
|
||||
}): TodayDashboardCell[] {
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
|
||||
if (options.showCharts) {
|
||||
const chartCount = countVisibleCharts(options.charts, options.orgType, options.isOwner);
|
||||
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
|
||||
cells.push({
|
||||
id: `chart-skeleton-${index}`,
|
||||
layout: TODAY_DASHBOARD_LAYOUT.chart,
|
||||
content: <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 />,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
showUpcoming: boolean;
|
||||
showCharts: boolean;
|
||||
orgType?: 'CLINIC' | 'LAB';
|
||||
isOwner: boolean;
|
||||
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||
}): TodayDashboardCell[] {
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
const formatDayLabel = (code: string) =>
|
||||
formatTodayChartDayLabel(code, options.dayLabelFormatter);
|
||||
|
||||
if (options.showCharts) {
|
||||
cells.push(
|
||||
...buildChartCells({
|
||||
t: options.t,
|
||||
charts: options.charts,
|
||||
orgType: options.orgType,
|
||||
isOwner: options.isOwner,
|
||||
formatDayLabel,
|
||||
dayLabelFormatter: options.dayLabelFormatter,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.showUpcoming) {
|
||||
cells.push({
|
||||
id: 'upcoming-appointments',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
|
||||
content: (
|
||||
<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) {
|
||||
const caseCompletion = options.charts.caseCompletion;
|
||||
cells.push({
|
||||
id: 'case-completion',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: (
|
||||
<TodayCaseCompletionKpiCard
|
||||
completed={caseCompletion.completed}
|
||||
total={caseCompletion.total}
|
||||
percent={caseCompletion.percent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
formatDayLabel: (code: string) => string;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
}): TodayDashboardCell[] {
|
||||
const { t, charts, orgType, isOwner } = options;
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
const tallChart = TODAY_DASHBOARD_LAYOUT.chart;
|
||||
const mediumChart = TODAY_DASHBOARD_LAYOUT.chartMedium;
|
||||
|
||||
const appointmentsWeekAllData = mapWeekChartBuckets(
|
||||
charts.appointmentsWeekAll ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
const appointmentsWeekMineData = mapWeekChartBuckets(
|
||||
charts.appointmentsWeekMine ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
const labTaskActivityData = mapWeekChartBuckets(
|
||||
charts.labTaskActivityWeek ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
|
||||
if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-week-all',
|
||||
layout: tallChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-week-mine',
|
||||
layout: tallChart,
|
||||
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: tallChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayStackedBarChart
|
||||
data={labTaskActivityData}
|
||||
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||
formatDayLabel={options.formatDayLabel}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const prosthesisData = charts.inProgressTasksByProsthesis ?? [];
|
||||
if (orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-prosthesis-mix',
|
||||
layout: tallChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartProsthesisMixTitle')}
|
||||
subtitle={t('chartProsthesisMixSubtitle')}
|
||||
isEmpty={prosthesisData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayDonutChart
|
||||
data={prosthesisData}
|
||||
labelForCode={(code) =>
|
||||
prosthesisData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||
swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)}
|
||||
variant="pie"
|
||||
sideLegend
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const efficiencyReportData = charts.efficiencyReport ?? [];
|
||||
if (
|
||||
isOwner &&
|
||||
charts.efficiencyReport !== undefined &&
|
||||
efficiencyReportData.length >= 2
|
||||
) {
|
||||
cells.push({
|
||||
id: 'chart-efficiency-report',
|
||||
layout: tallChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartEfficiencyReportTitle')}
|
||||
subtitle={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartEfficiencyReportSubtitleClinic')
|
||||
: t('chartEfficiencyReportSubtitleLab')
|
||||
}
|
||||
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayDonutChart
|
||||
data={efficiencyReportData}
|
||||
labelForCode={(code) =>
|
||||
efficiencyReportData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
variant="pie"
|
||||
sideLegend
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||
if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-by-provider',
|
||||
layout: mediumChart,
|
||||
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: mediumChart,
|
||||
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 tasksData = charts.tasksByWorkflowStep ?? [];
|
||||
if (orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-tasks-by-step',
|
||||
layout: mediumChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartTasksByStepTitle')}
|
||||
subtitle={t('chartTasksByStepSubtitle')}
|
||||
isEmpty={tasksData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart data={tasksData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function countVisibleCharts(
|
||||
charts: TodaySummaryCharts,
|
||||
orgType?: 'CLINIC' | 'LAB',
|
||||
isOwner = false,
|
||||
): number {
|
||||
let count = 0;
|
||||
if (orgType === 'CLINIC') {
|
||||
count += charts.appointmentsWeekAll !== undefined ? 1 : 0;
|
||||
count += charts.appointmentsWeekMine !== undefined ? 1 : 0;
|
||||
count += charts.appointmentsByProvider !== undefined ? 1 : 0;
|
||||
count += charts.treatmentMixWeek !== undefined ? 1 : 0;
|
||||
}
|
||||
if (orgType === 'LAB') {
|
||||
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||
count += charts.inProgressTasksByProsthesis !== undefined ? 1 : 0;
|
||||
count += charts.tasksByWorkflowStep !== undefined ? 1 : 0;
|
||||
}
|
||||
if (
|
||||
isOwner &&
|
||||
charts.efficiencyReport !== undefined &&
|
||||
(charts.efficiencyReport?.length ?? 0) >= 2
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
44
frontend/src/components/today/TodayDashboardGrid.tsx
Normal file
44
frontend/src/components/today/TodayDashboardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
TODAY_CHART_COLORS,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
@@ -44,7 +45,8 @@ export function TodayDonutChart({
|
||||
const outerRadius = sideLegend ? 100 : 92;
|
||||
|
||||
const chart = (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
@@ -70,6 +72,7 @@ export function TodayDonutChart({
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
|
||||
if (!sideLegend) {
|
||||
@@ -80,9 +83,9 @@ export function TodayDonutChart({
|
||||
const legendInset = 'px-12';
|
||||
|
||||
return (
|
||||
<div className={`flex h-full min-h-[220px] items-center ${legendInset}`}>
|
||||
<div className="flex min-w-0 flex-1 items-center overflow-y-auto max-h-full py-0.5">
|
||||
<div className="flex flex-col items-start gap-1.5 shrink-0">
|
||||
<div className={`flex h-full min-h-0 items-center overflow-hidden ${legendInset}`}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center overflow-hidden py-0.5">
|
||||
<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
|
||||
@@ -94,7 +97,7 @@ export function TodayDonutChart({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex flex-col items-start gap-1.5">
|
||||
<div className="ml-2 flex min-w-0 flex-col items-start gap-1.5 overflow-hidden">
|
||||
{chartData.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
@@ -117,8 +120,33 @@ export function TodayDonutChart({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex h-[240px] w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
|
||||
{chart}
|
||||
<div className="ml-4 flex h-full min-h-0 w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
@@ -29,11 +30,12 @@ export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps)
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 36)}>
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, left: 4, bottom: 0 }}
|
||||
margin={{ top: 4, right: 12, left: 4, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
||||
<XAxis
|
||||
@@ -69,6 +71,7 @@ export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps)
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { KpiCard } from '@/components/today/KpiCard';
|
||||
import { KpiCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import type { TodaySummaryWidgets } from '@/types/today';
|
||||
|
||||
interface TodayKpiGridProps {
|
||||
widgets: TodaySummaryWidgets;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function TodayKpiGrid({
|
||||
widgets,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
}: TodayKpiGridProps) {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const definitions = isInitialLoad
|
||||
? getEligibleTodayKpis(currentOrganization)
|
||||
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||
|
||||
if (hasError && !loading && definitions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!loading && !hasError && definitions.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
const skeletonCount = Math.max(getEligibleTodayKpis(currentOrganization).length, 4);
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: skeletonCount }, (_, index) => (
|
||||
<KpiCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||
>
|
||||
{definitions.map((definition) => {
|
||||
const value = definition.formatValue(widgets) ?? '—';
|
||||
const subtitleKey = definition.formatSubtitle?.(widgets);
|
||||
const subtitle =
|
||||
subtitleKey === 'unlimited'
|
||||
? t('seatsUnlimited')
|
||||
: definition.formatSubtitle?.(widgets);
|
||||
|
||||
return (
|
||||
<KpiCard
|
||||
key={definition.key}
|
||||
title={t(definition.titleKey)}
|
||||
value={value}
|
||||
subtitle={subtitle}
|
||||
icon={definition.icon}
|
||||
color={definition.color}
|
||||
href={definition.href}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts';
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
|
||||
|
||||
@@ -10,6 +15,9 @@ interface TodayRadialGaugeChartProps {
|
||||
total: number;
|
||||
percentLabel: string;
|
||||
tasksLabel: string;
|
||||
size?: 'sm' | 'md';
|
||||
fillColor?: string;
|
||||
showRatio?: boolean;
|
||||
}
|
||||
|
||||
export function TodayRadialGaugeChart({
|
||||
@@ -18,35 +26,48 @@ export function TodayRadialGaugeChart({
|
||||
total,
|
||||
percentLabel,
|
||||
tasksLabel,
|
||||
size = 'md',
|
||||
fillColor = TODAY_CHART_PRIMARY_COLOR,
|
||||
showRatio = true,
|
||||
}: TodayRadialGaugeChartProps) {
|
||||
const isCompact = size === 'sm';
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }];
|
||||
const data = [{ name: 'progress', value: clamped, fill: fillColor }];
|
||||
|
||||
return (
|
||||
<div className="relative h-[240px] w-full">
|
||||
<div className={`relative w-full ${isCompact ? 'h-[108px]' : 'h-full min-h-0 flex-1'}`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="68%"
|
||||
innerRadius={isCompact ? '62%' : '68%'}
|
||||
outerRadius="100%"
|
||||
barSize={14}
|
||||
barSize={isCompact ? 9 : 14}
|
||||
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={8}
|
||||
cornerRadius={isCompact ? 6 : 8}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-3xl font-semibold text-text-primary">{percentLabel}</span>
|
||||
<span className="mt-1 text-xs text-text-muted">{tasksLabel}</span>
|
||||
{total > 0 ? (
|
||||
<span className="mt-0.5 text-[11px] text-text-secondary">
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center 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}
|
||||
|
||||
@@ -11,22 +11,24 @@ export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCardSkeleton() {
|
||||
export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-card-border bg-card p-4">
|
||||
<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="h-8 w-16 mt-3" />
|
||||
<SkeletonBlock className="h-3 w-1/3 mt-2" />
|
||||
<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="rounded-[var(--radius-lg)] border border-card-border bg-card p-4 min-h-[280px] flex flex-col">
|
||||
<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="flex-1 min-h-[220px] mt-4" />
|
||||
<SkeletonBlock className="min-h-0 flex-1 mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import type { TodayStackedDayBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
@@ -38,8 +39,9 @@ export function TodayStackedBarChart({
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 28 }}>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="dayLabel"
|
||||
@@ -63,7 +65,8 @@ export function TodayStackedBarChart({
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR }}
|
||||
verticalAlign="bottom"
|
||||
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR, paddingTop: 8 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
@@ -83,5 +86,6 @@ export function TodayStackedBarChart({
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
84
frontend/src/components/today/TodaySubscriptionKpiCard.tsx
Normal file
84
frontend/src/components/today/TodaySubscriptionKpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,6 @@ interface TodayUpcomingAppointmentsProps {
|
||||
isInitialLoad?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
export function TodayUpcomingAppointments({
|
||||
actions,
|
||||
loading = false,
|
||||
@@ -48,11 +46,11 @@ export function TodayUpcomingAppointments({
|
||||
return null;
|
||||
}
|
||||
|
||||
const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE);
|
||||
const appointments = actions.upcomingAppointmentsToday ?? [];
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<Card className="min-h-[140px] p-3">
|
||||
<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" />
|
||||
@@ -67,7 +65,7 @@ export function TodayUpcomingAppointments({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="min-h-[140px] p-3">
|
||||
<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">
|
||||
@@ -88,12 +86,15 @@ export function TodayUpcomingAppointments({
|
||||
<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 purposeIndex = treatmentCatalog.findIndex(
|
||||
(entry) => entry.code === appointment.purpose,
|
||||
);
|
||||
const purposeTextColor = treatmentTypeColor(
|
||||
appointment.purpose,
|
||||
purposeIndex < 0 ? 0 : purposeIndex,
|
||||
@@ -126,6 +127,7 @@ export function TodayUpcomingAppointments({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
135
frontend/src/components/today/today-dashboard-layout.ts
Normal file
135
frontend/src/components/today/today-dashboard-layout.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */
|
||||
export type TodayDashboardWidth = 1 | 2;
|
||||
export type TodayDashboardHeight = 1 | 2 | 3;
|
||||
|
||||
export interface TodayDashboardLayout {
|
||||
width: TodayDashboardWidth;
|
||||
height: TodayDashboardHeight;
|
||||
}
|
||||
|
||||
/** Shared layout presets — assign when registering a dashboard widget. */
|
||||
export const TODAY_DASHBOARD_LAYOUT = {
|
||||
kpi: { width: 1, height: 1 },
|
||||
subscription: { width: 1, height: 2 },
|
||||
upcoming: { width: 2, height: 3 },
|
||||
/** Tall charts: area, stacked bar, pie with side legend */
|
||||
chart: { width: 2, height: 3 },
|
||||
/** Medium charts: horizontal bar, vertical bar, radial gauge */
|
||||
chartMedium: { width: 2, height: 2 },
|
||||
} as const satisfies Record<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;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */
|
||||
export function allowedStartColumns(
|
||||
width: number,
|
||||
columns: number,
|
||||
): number[] {
|
||||
if (width <= 1) {
|
||||
return Array.from({ length: columns }, (_, index) => index);
|
||||
}
|
||||
|
||||
if (width === 2 && columns === 4) {
|
||||
return [0, 2];
|
||||
}
|
||||
|
||||
return Array.from({ length: columns - width + 1 }, (_, index) => index);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-fit placement in ascending layout order (top-left scan).
|
||||
* Multi-column widgets may only start at aligned column pairs (1–2 or 3–4 on a 4-col grid).
|
||||
*/
|
||||
export function packDashboardCells(
|
||||
cells: TodayDashboardCell[],
|
||||
columns = 4,
|
||||
): PackedDashboardCell[] {
|
||||
const sorted = sortDashboardCells(cells);
|
||||
const occupied = new Set<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';
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canEditStaff,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewStaff,
|
||||
canViewTasks,
|
||||
@@ -67,7 +67,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
color: 'blue',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/appointments',
|
||||
isVisible: (org) => canAccessAppointmentsSection(org),
|
||||
isVisible: (org) => canViewAppointmentsTab(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'appointmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
@@ -80,7 +80,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
color: 'green',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/patients',
|
||||
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
|
||||
isVisible: (org) => canViewPatients(org) || canViewAppointmentsTab(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'patientsToday');
|
||||
return count === null ? null : String(count);
|
||||
@@ -203,26 +203,6 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seats',
|
||||
titleKey: 'widgetSeats',
|
||||
icon: UserCog,
|
||||
color: 'default',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
if (seats.unlimited) return String(seats.used);
|
||||
return `${seats.used}/${seats.limit ?? 0}`;
|
||||
},
|
||||
formatSubtitle: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
return seats.unlimited ? 'unlimited' : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingStaffInvites',
|
||||
titleKey: 'widgetPendingStaffInvites',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { OrgTypeName } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewTasks,
|
||||
canViewTab,
|
||||
@@ -73,7 +73,7 @@ function Sidebar() {
|
||||
return false;
|
||||
}
|
||||
if (item.path === '/appointments') {
|
||||
return canAccessAppointmentsSection(currentOrganization);
|
||||
return canViewAppointmentsTab(currentOrganization);
|
||||
}
|
||||
if (item.path === '/cases') {
|
||||
return canViewCases(currentOrganization);
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
--today-grid-unit: 5.75rem;
|
||||
--color-background-primary: #000c1c;
|
||||
--color-background-secondary: #0a1520;
|
||||
--color-background-card: #14253d;
|
||||
@@ -272,6 +272,13 @@ select option {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.today-dashboard-grid .today-dashboard-cell {
|
||||
grid-column: var(--today-gc);
|
||||
grid-row: var(--today-gr);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .surface-card,
|
||||
:root:not([data-theme='light']) .surface-card {
|
||||
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TodaySummaryCharts = {
|
||||
appointmentsWeekMine?: TodayChartBucket[];
|
||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||
inProgressTasksByProsthesis?: TodayChartBucket[];
|
||||
efficiencyReport?: TodayChartBucket[];
|
||||
};
|
||||
|
||||
export type TodayWidgetKey =
|
||||
@@ -49,10 +50,23 @@ export type TodayWidgetKey =
|
||||
| 'tasksInProgress'
|
||||
| 'importantTasks'
|
||||
| 'pendingConnections'
|
||||
| 'seats'
|
||||
| 'pendingStaffInvites'
|
||||
| 'providersWithoutWorkingHours';
|
||||
|
||||
export type TodaySubscriptionSnapshot = {
|
||||
hasActivePlan: boolean;
|
||||
planName: string | null;
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | null;
|
||||
seatsUnlimited: boolean;
|
||||
seatsPercent: number;
|
||||
periodStartAt: string;
|
||||
periodEndAt: string | null;
|
||||
periodTotalDays: number;
|
||||
periodElapsedDays: number;
|
||||
periodPercent: number;
|
||||
};
|
||||
|
||||
export type TodaySummaryWidgets = Partial<
|
||||
Record<
|
||||
TodayWidgetKey,
|
||||
@@ -68,6 +82,7 @@ export interface TodaySummaryData {
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export interface TodaySummaryResponse {
|
||||
|
||||
Reference in New Issue
Block a user