Compare commits
3 Commits
ed8ff61205
...
0f1004a34a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f1004a34a | |||
| d79aab907c | |||
| 83f6003a2b |
@@ -23,6 +23,7 @@
|
|||||||
"prisma:deploy": "prisma migrate deploy",
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
"prisma:seed": "prisma db seed",
|
"prisma:seed": "prisma db seed",
|
||||||
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
|
"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:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"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')
|
@Get('column-providers')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
columnProviders(
|
||||||
@Query() query: ColumnProvidersQueryDto,
|
@Query() query: ColumnProvidersQueryDto,
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export class AppointmentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
|
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 from = new Date(query.from);
|
||||||
const to = new Date(query.to);
|
const to = new Date(query.to);
|
||||||
@@ -95,6 +98,7 @@ export class AppointmentsService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
startAt: { lt: to },
|
startAt: { lt: to },
|
||||||
endAt: { gt: from },
|
endAt: { gt: from },
|
||||||
|
...(scopeToProvider ? { providerUserId: actorUserId } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
patient: {
|
patient: {
|
||||||
@@ -256,18 +260,34 @@ export class AppointmentsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const names = m.permissions.map((p) => p.permission.name);
|
const names = m.permissions.map((p) => p.permission.name);
|
||||||
if (names.includes('TAB_APPOINTMENTS_READ')) {
|
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (names.includes('TAB_TREATMENT_READ')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new ForbiddenException('You do not have access to appointments');
|
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) {
|
private async assertCanEditAppointments(userId: string, organizationId: string) {
|
||||||
const m = await this.getMembership(userId, organizationId);
|
const m = await this.getMembership(userId, organizationId);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
@@ -280,9 +300,6 @@ export class AppointmentsService {
|
|||||||
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new ForbiddenException('You cannot create or modify appointments');
|
throw new ForbiddenException('You cannot create or modify appointments');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,21 +30,30 @@ type StackedDayBucket = {
|
|||||||
received: number;
|
received: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CaseCompletionChart = {
|
type CompletionGaugeChart = {
|
||||||
completed: number;
|
completed: number;
|
||||||
total: number;
|
total: number;
|
||||||
percent: number;
|
percent: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PartnerCasesBucket = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
type TodayCharts = {
|
type TodayCharts = {
|
||||||
treatmentMixWeek?: ChartBucket[];
|
treatmentMixWeek?: ChartBucket[];
|
||||||
tasksByWorkflowStep?: ChartBucket[];
|
tasksByProsthesis?: ChartBucket[];
|
||||||
appointmentsByProvider?: ChartBucket[];
|
appointmentsByProvider?: ChartBucket[];
|
||||||
caseCompletion?: CaseCompletionChart;
|
caseCompletion?: CompletionGaugeChart;
|
||||||
|
treatmentPlanCompletion?: CompletionGaugeChart;
|
||||||
appointmentsWeekAll?: ChartBucket[];
|
appointmentsWeekAll?: ChartBucket[];
|
||||||
appointmentsWeekMine?: ChartBucket[];
|
appointmentsWeekMine?: ChartBucket[];
|
||||||
labTaskActivityWeek?: StackedDayBucket[];
|
labTaskActivityWeek?: StackedDayBucket[];
|
||||||
inProgressTasksByProsthesis?: ChartBucket[];
|
casePartnersMonth?: PartnerCasesBucket[];
|
||||||
|
efficiencyReport?: ChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TodayActions = {
|
type TodayActions = {
|
||||||
@@ -57,18 +66,30 @@ 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 = {
|
type TodayWidgets = {
|
||||||
appointmentsToday?: { count: number };
|
appointmentsToday?: { count: number };
|
||||||
patientsToday?: { count: number };
|
patientsToday?: { count: number };
|
||||||
treatmentsToday?: { count: number };
|
treatmentsToday?: { count: number };
|
||||||
draftTreatments?: { count: number };
|
|
||||||
labCasesPendingSend?: { count: number };
|
labCasesPendingSend?: { count: number };
|
||||||
casesReceivedToday?: { count: number };
|
casesReceivedToday?: { count: number };
|
||||||
casesInProgress?: { count: number };
|
casesInProgress?: { count: number };
|
||||||
tasksInProgress?: { count: number };
|
tasksInProgress?: { count: number };
|
||||||
importantTasks?: { count: number };
|
importantTasks?: { count: number };
|
||||||
pendingConnections?: { count: number };
|
pendingConnections?: { count: number };
|
||||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
|
||||||
pendingStaffInvites?: { count: number };
|
pendingStaffInvites?: { count: number };
|
||||||
providersWithoutWorkingHours?: { count: number };
|
providersWithoutWorkingHours?: { count: number };
|
||||||
};
|
};
|
||||||
@@ -106,6 +127,7 @@ export class TodayService {
|
|||||||
const charts: TodayCharts = {};
|
const charts: TodayCharts = {};
|
||||||
const actions: TodayActions = {};
|
const actions: TodayActions = {};
|
||||||
const tasks: Promise<void>[] = [];
|
const tasks: Promise<void>[] = [];
|
||||||
|
let subscription: TodaySubscriptionWidget | undefined;
|
||||||
|
|
||||||
if (orgType === 'CLINIC') {
|
if (orgType === 'CLINIC') {
|
||||||
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
||||||
@@ -125,15 +147,47 @@ export class TodayService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.canEditTreatment(membership.isOwner, permissionNames)) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadCasePartnersMonth(
|
||||||
|
'CLINIC',
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
!membership.isOwner,
|
||||||
|
to,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tasks.push(
|
||||||
|
this.loadTreatmentPlanCompletion(
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
!membership.isOwner,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
|
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadTreatmentsToday(organizationId, from, to, widgets),
|
this.loadTreatmentsToday(organizationId, from, to, widgets),
|
||||||
);
|
);
|
||||||
tasks.push(this.loadDraftTreatments(organizationId, widgets));
|
|
||||||
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canViewMyAppointmentsWeekChart(membership.isOwner, permissionNames)) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadUpcomingAppointmentsToday(
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
actions,
|
||||||
|
),
|
||||||
|
);
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadAppointmentsWeekMine(
|
this.loadAppointmentsWeekMine(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -143,15 +197,6 @@ export class TodayService {
|
|||||||
charts,
|
charts,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
tasks.push(
|
|
||||||
this.loadUpcomingAppointmentsToday(
|
|
||||||
organizationId,
|
|
||||||
userId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
actions,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -183,10 +228,23 @@ export class TodayService {
|
|||||||
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.canEditCases(membership.isOwner, permissionNames)) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadCasePartnersMonth(
|
||||||
|
'LAB',
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
false,
|
||||||
|
to,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||||
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
||||||
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
||||||
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
|
tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canViewLabWork) {
|
if (canViewLabWork) {
|
||||||
@@ -198,9 +256,6 @@ export class TodayService {
|
|||||||
charts,
|
charts,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
tasks.push(
|
|
||||||
this.loadInProgressTasksByProsthesis(organizationId, locale, charts),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +265,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)) {
|
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||||
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
|
|
||||||
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +296,7 @@ export class TodayService {
|
|||||||
widgets,
|
widgets,
|
||||||
charts,
|
charts,
|
||||||
actions,
|
actions,
|
||||||
|
...(subscription ? { subscription } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,12 +345,13 @@ export class TodayService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadTasksByWorkflowStep(
|
private async loadTasksByProsthesis(
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
|
locale: CatalogLocale,
|
||||||
charts: TodayCharts,
|
charts: TodayCharts,
|
||||||
) {
|
) {
|
||||||
const grouped = await this.prisma.labCaseTask.groupBy({
|
const grouped = await this.prisma.labCaseTask.groupBy({
|
||||||
by: ['workflowStepCode', 'stepLabel'],
|
by: ['prosthesisTypeCode'],
|
||||||
where: {
|
where: {
|
||||||
status: LabTaskStatus.IN_PROGRESS,
|
status: LabTaskStatus.IN_PROGRESS,
|
||||||
labCase: {
|
labCase: {
|
||||||
@@ -290,14 +362,29 @@ export class TodayService {
|
|||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
charts.tasksByWorkflowStep = grouped
|
const sorted = grouped
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
code: row.workflowStepCode,
|
code: row.prosthesisTypeCode,
|
||||||
label: row.stepLabel,
|
|
||||||
count: aggregateCount(row._count),
|
count: aggregateCount(row._count),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count);
|
||||||
.slice(0, 10);
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
charts.tasksByProsthesis = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
sorted.map((row) => row.code),
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
charts.tasksByProsthesis = sorted.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
label: labels.get(row.code) ?? row.code,
|
||||||
|
count: row.count,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
|
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
|
||||||
@@ -356,7 +443,7 @@ export class TodayService {
|
|||||||
patient: { select: { firstName: true, lastName: true } },
|
patient: { select: { firstName: true, lastName: true } },
|
||||||
},
|
},
|
||||||
orderBy: { startAt: 'asc' },
|
orderBy: { startAt: 'asc' },
|
||||||
take: 3,
|
take: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
||||||
@@ -401,16 +488,6 @@ export class TodayService {
|
|||||||
widgets.treatmentsToday = { count };
|
widgets.treatmentsToday = { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
|
|
||||||
const count = await this.prisma.treatment.count({
|
|
||||||
where: {
|
|
||||||
organizationId,
|
|
||||||
details: { none: {} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
widgets.draftTreatments = { count };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
|
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
|
||||||
const count = await this.prisma.labCase.count({
|
const count = await this.prisma.labCase.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -481,25 +558,205 @@ export class TodayService {
|
|||||||
widgets.pendingConnections = { count };
|
widgets.pendingConnections = { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSeats(
|
private async getActiveEditAccessMembers(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
plan: { maxUsers: number } | null,
|
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
|
||||||
widgets: TodayWidgets,
|
): Promise<Array<{ userId: string; isOwner: boolean }>> {
|
||||||
|
const members = await this.prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ isOwner: true },
|
||||||
|
{
|
||||||
|
isOwner: false,
|
||||||
|
permissions: {
|
||||||
|
some: { permission: { name: editPermission } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { userId: true, isOwner: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return members.map((member) => ({
|
||||||
|
userId: member.userId,
|
||||||
|
isOwner: member.isOwner,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildEfficiencyReportRows(
|
||||||
|
members: Array<{ userId: string; isOwner: boolean }>,
|
||||||
|
countsByUser: Map<string, number>,
|
||||||
|
): Promise<ChartBucket[] | undefined> {
|
||||||
|
if (members.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = members.map((member) => member.userId);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
|
||||||
|
const rows = members
|
||||||
|
.map((member) => ({
|
||||||
|
code: member.userId,
|
||||||
|
label: nameById.get(member.userId) ?? member.userId,
|
||||||
|
count: countsByUser.get(member.userId) ?? 0,
|
||||||
|
isOwner: member.isOwner,
|
||||||
|
}))
|
||||||
|
.filter((row) => !row.isOwner || row.count > 0)
|
||||||
|
.map(({ code, label, count }) => ({ code, label, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return rows.length >= 2 ? rows : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadClinicEfficiencyReport(
|
||||||
|
organizationId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
charts: TodayCharts,
|
||||||
) {
|
) {
|
||||||
const used = await this.prisma.membership.count({
|
const members = await this.getActiveEditAccessMembers(
|
||||||
|
organizationId,
|
||||||
|
'TAB_TREATMENT_EDIT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
|
const grouped = await this.prisma.treatment.groupBy({
|
||||||
|
by: ['providerUserId'],
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
treatmentAt: { gte: monthStart, lt: rangeEnd },
|
||||||
|
providerUserId: { in: members.map((member) => member.userId) },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsByUser = new Map(
|
||||||
|
members.map((member) => [member.userId, 0]),
|
||||||
|
);
|
||||||
|
for (const row of grouped) {
|
||||||
|
countsByUser.set(row.providerUserId, aggregateCount(row._count));
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await this.buildEfficiencyReportRows(members, countsByUser);
|
||||||
|
if (report) {
|
||||||
|
charts.efficiencyReport = report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLabEfficiencyReport(
|
||||||
|
labOrganizationId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const members = await this.getActiveEditAccessMembers(
|
||||||
|
labOrganizationId,
|
||||||
|
'TAB_TASKS_EDIT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
|
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
|
||||||
|
by: ['changedByUserId'],
|
||||||
|
where: {
|
||||||
|
toStatus: LabTaskStatus.COMPLETED,
|
||||||
|
changedAt: { gte: monthStart, lt: rangeEnd },
|
||||||
|
changedByUserId: { in: members.map((member) => member.userId) },
|
||||||
|
task: {
|
||||||
|
labCase: {
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsByUser = new Map(
|
||||||
|
members.map((member) => [member.userId, 0]),
|
||||||
|
);
|
||||||
|
for (const row of grouped) {
|
||||||
|
if (!row.changedByUserId) continue;
|
||||||
|
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await this.buildEfficiencyReportRows(members, countsByUser);
|
||||||
|
if (report) {
|
||||||
|
charts.efficiencyReport = report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildSubscriptionWidget(
|
||||||
|
organizationId: string,
|
||||||
|
organization: {
|
||||||
|
createdAt: Date;
|
||||||
|
plan: { name: string; maxUsers: number } | null;
|
||||||
|
},
|
||||||
|
): Promise<TodaySubscriptionWidget> {
|
||||||
|
const seatsUsed = await this.prisma.membership.count({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
OR: [{ isOwner: true }, { isActive: true }],
|
OR: [{ isOwner: true }, { isActive: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxUsers = plan?.maxUsers ?? 0;
|
const plan = organization.plan;
|
||||||
const unlimited = isUnlimitedSeats(maxUsers);
|
const periodStartAt = organization.createdAt.toISOString();
|
||||||
|
|
||||||
widgets.seats = {
|
if (!plan) {
|
||||||
used,
|
return {
|
||||||
limit: unlimited ? null : maxUsers,
|
hasActivePlan: false,
|
||||||
unlimited,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,13 +896,42 @@ export class TodayService {
|
|||||||
select: { status: true },
|
select: { status: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const total = tasks.length;
|
charts.caseCompletion = this.buildCompletionGauge(
|
||||||
const completed = tasks.filter(
|
tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length,
|
||||||
(task) => task.status === LabTaskStatus.COMPLETED,
|
tasks.length,
|
||||||
).length;
|
);
|
||||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
}
|
||||||
|
|
||||||
charts.caseCompletion = { completed, total, percent };
|
private async loadTreatmentPlanCompletion(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
scopeToUser: boolean,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const appointmentWhere = {
|
||||||
|
organizationId,
|
||||||
|
...(scopeToUser ? { providerUserId: userId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, completed] = await Promise.all([
|
||||||
|
this.prisma.appointment.count({ where: appointmentWhere }),
|
||||||
|
this.prisma.appointment.count({
|
||||||
|
where: {
|
||||||
|
...appointmentWhere,
|
||||||
|
treatment: { details: { some: {} } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
charts.treatmentPlanCompletion = this.buildCompletionGauge(completed, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCompletionGauge(completed: number, total: number): CompletionGaugeChart {
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
percent: total > 0 ? Math.round((completed / total) * 100) : 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAppointmentsWeekAll(
|
private async loadAppointmentsWeekAll(
|
||||||
@@ -780,45 +1066,83 @@ export class TodayService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadInProgressTasksByProsthesis(
|
private async loadCasePartnersMonth(
|
||||||
labOrganizationId: string,
|
orgType: 'CLINIC' | 'LAB',
|
||||||
locale: CatalogLocale,
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
scopeToUser: boolean,
|
||||||
|
rangeEnd: Date,
|
||||||
charts: TodayCharts,
|
charts: TodayCharts,
|
||||||
) {
|
) {
|
||||||
const grouped = await this.prisma.labCaseTask.groupBy({
|
const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
by: ['prosthesisTypeCode'],
|
|
||||||
|
const cases = await this.prisma.labCase.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: LabTaskStatus.IN_PROGRESS,
|
sentAt: { gte: rangeStart, lt: rangeEnd },
|
||||||
labCase: {
|
...(orgType === 'CLINIC'
|
||||||
sentAt: { not: null },
|
? {
|
||||||
sends: { some: { organizationId: labOrganizationId } },
|
destinationOrganizationId: { not: null },
|
||||||
},
|
treatment: {
|
||||||
|
organizationId,
|
||||||
|
...(scopeToUser ? { providerUserId: userId } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
sends: { some: { organizationId } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
destinationOrganizationId: true,
|
||||||
|
tasks: { select: { status: true } },
|
||||||
|
treatment: { select: { organizationId: true } },
|
||||||
},
|
},
|
||||||
_count: { _all: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = grouped
|
const countsByPartner = new Map<string, { completed: number; total: number }>();
|
||||||
.map((row) => ({
|
|
||||||
code: row.prosthesisTypeCode,
|
|
||||||
count: aggregateCount(row._count),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
for (const labCase of cases) {
|
||||||
charts.inProgressTasksByProsthesis = [];
|
const partnerId =
|
||||||
|
orgType === 'CLINIC'
|
||||||
|
? labCase.destinationOrganizationId
|
||||||
|
: labCase.treatment.organizationId;
|
||||||
|
if (!partnerId) continue;
|
||||||
|
|
||||||
|
const isCompleted =
|
||||||
|
labCase.tasks.length > 0 &&
|
||||||
|
labCase.tasks.every((task) => task.status === LabTaskStatus.COMPLETED);
|
||||||
|
|
||||||
|
const entry = countsByPartner.get(partnerId) ?? { completed: 0, total: 0 };
|
||||||
|
entry.total += 1;
|
||||||
|
if (isCompleted) entry.completed += 1;
|
||||||
|
countsByPartner.set(partnerId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countsByPartner.size === 0) {
|
||||||
|
charts.casePartnersMonth = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = await this.catalogLabels.resolveLabels(
|
const sorted = [...countsByPartner.entries()]
|
||||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
.map(([code, counts]) => ({
|
||||||
sorted.map((row) => row.code),
|
code,
|
||||||
locale,
|
completed: counts.completed,
|
||||||
);
|
pending: counts.total - counts.completed,
|
||||||
|
total: counts.total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
charts.inProgressTasksByProsthesis = sorted.map((row) => ({
|
const partners = await this.prisma.organization.findMany({
|
||||||
|
where: { id: { in: sorted.map((row) => row.code) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(partners.map((org) => [org.id, org.name]));
|
||||||
|
|
||||||
|
charts.casePartnersMonth = sorted.map((row) => ({
|
||||||
code: row.code,
|
code: row.code,
|
||||||
label: labels.get(row.code) ?? row.code,
|
label: nameById.get(row.code) ?? row.code,
|
||||||
count: row.count,
|
completed: row.completed,
|
||||||
|
pending: row.pending,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,12 +1209,7 @@ export class TodayService {
|
|||||||
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
|
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
return names.some((p) =>
|
return names.some((p) =>
|
||||||
[
|
['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'].includes(p),
|
||||||
'TAB_APPOINTMENTS_READ',
|
|
||||||
'TAB_APPOINTMENTS_EDIT',
|
|
||||||
'TAB_TREATMENT_READ',
|
|
||||||
'TAB_TREATMENT_EDIT',
|
|
||||||
].includes(p),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,6 +1227,11 @@ export class TodayService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean {
|
||||||
|
if (isOwner) return false;
|
||||||
|
return names.includes('TAB_TREATMENT_EDIT');
|
||||||
|
}
|
||||||
|
|
||||||
private canViewCases(isOwner: boolean, names: string[]): boolean {
|
private canViewCases(isOwner: boolean, names: string[]): boolean {
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
return names.some((p) =>
|
return names.some((p) =>
|
||||||
@@ -922,6 +1246,16 @@ export class TodayService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canEditTreatment(isOwner: boolean, names: string[]): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return names.includes('TAB_TREATMENT_EDIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
private canEditCases(isOwner: boolean, names: string[]): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return names.includes('TAB_CASES_EDIT');
|
||||||
|
}
|
||||||
|
|
||||||
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
|
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
return names.includes('TAB_ORGANIZATIONS_EDIT');
|
return names.includes('TAB_ORGANIZATIONS_EDIT');
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
|||||||
@@ -208,8 +208,17 @@
|
|||||||
"widgetImportantTasks": "Important Tasks",
|
"widgetImportantTasks": "Important Tasks",
|
||||||
"widgetPendingConnections": "Pending Connections",
|
"widgetPendingConnections": "Pending Connections",
|
||||||
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||||
"widgetSeats": "Seat Usage",
|
|
||||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
"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",
|
"chartAppointmentsWeekAllTitle": "Appointments This Week",
|
||||||
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
||||||
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
||||||
@@ -218,8 +227,6 @@
|
|||||||
"chartLabTaskActivitySubtitle": "Last 7 days",
|
"chartLabTaskActivitySubtitle": "Last 7 days",
|
||||||
"chartLabTaskCompletedLegend": "Completed",
|
"chartLabTaskCompletedLegend": "Completed",
|
||||||
"chartLabTaskReceivedLegend": "Received",
|
"chartLabTaskReceivedLegend": "Received",
|
||||||
"chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis",
|
|
||||||
"chartProsthesisMixSubtitle": "Current workload mix",
|
|
||||||
"chartAppointmentsByProviderTitle": "Appointments by Provider",
|
"chartAppointmentsByProviderTitle": "Appointments by Provider",
|
||||||
"chartAppointmentsByProviderSubtitle": "Today",
|
"chartAppointmentsByProviderSubtitle": "Today",
|
||||||
"chartTreatmentMixTitle": "Treatment Mix",
|
"chartTreatmentMixTitle": "Treatment Mix",
|
||||||
@@ -228,8 +235,19 @@
|
|||||||
"chartCaseCompletionSubtitle": "All active cases",
|
"chartCaseCompletionSubtitle": "All active cases",
|
||||||
"chartCaseCompletionPercent": "{percent}%",
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
"chartCaseCompletionTasks": "Tasks completed",
|
"chartCaseCompletionTasks": "Tasks completed",
|
||||||
"chartTasksByStepTitle": "Tasks by Workflow Step",
|
"chartTreatmentPlanCompletionTitle": "Treatment Plan Completion",
|
||||||
"chartTasksByStepSubtitle": "In progress now",
|
"chartTreatmentPlanCompletionSubtitle": "All appointments",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
||||||
|
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
||||||
|
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
||||||
|
"chartCasePartnersClinicTitle": "Cases by Lab",
|
||||||
|
"chartCasePartnersLabTitle": "Cases by Clinic",
|
||||||
|
"chartCasePartnersSubtitle": "Last 30 days",
|
||||||
|
"chartCasePartnersSentLegend": "Sent",
|
||||||
|
"chartCasePartnersOpenLegend": "In progress",
|
||||||
|
"chartEfficiencyReportTitle": "Efficiency Report",
|
||||||
|
"chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days",
|
||||||
|
"chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days",
|
||||||
"chartEmpty": "No data for this period yet.",
|
"chartEmpty": "No data for this period yet.",
|
||||||
"upcomingAppointmentsTitle": "Upcoming Today",
|
"upcomingAppointmentsTitle": "Upcoming Today",
|
||||||
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
|
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
|
||||||
|
|||||||
@@ -208,8 +208,17 @@
|
|||||||
"widgetImportantTasks": "وظایف مهم",
|
"widgetImportantTasks": "وظایف مهم",
|
||||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||||
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||||
"widgetSeats": "استفاده از صندلی",
|
|
||||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||||
|
"widgetSubscription": "اشتراک",
|
||||||
|
"subscriptionSeatsLabel": "صندلیهای استفادهشده",
|
||||||
|
"subscriptionSeatsRemainingLabel": "صندلی باقیمانده",
|
||||||
|
"subscriptionSeatsPercent": "{percent}٪",
|
||||||
|
"subscriptionSeatsUnlimitedShort": "نامحدود",
|
||||||
|
"subscriptionPeriodLabel": "دوره اشتراک",
|
||||||
|
"subscriptionPeriodRemainingLabel": "روز باقیمانده",
|
||||||
|
"subscriptionPeriodPercent": "{percent}٪",
|
||||||
|
"subscriptionPeriodDays": "{elapsed}/{total} روز",
|
||||||
|
"subscriptionNoPlan": "اشتراک فعال نیست",
|
||||||
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
||||||
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
||||||
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
||||||
@@ -218,8 +227,6 @@
|
|||||||
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
|
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
|
||||||
"chartLabTaskCompletedLegend": "تکمیلشده",
|
"chartLabTaskCompletedLegend": "تکمیلشده",
|
||||||
"chartLabTaskReceivedLegend": "دریافتشده",
|
"chartLabTaskReceivedLegend": "دریافتشده",
|
||||||
"chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز",
|
|
||||||
"chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی",
|
|
||||||
"chartAppointmentsByProviderTitle": "نوبتها بر اساس ارائهدهنده",
|
"chartAppointmentsByProviderTitle": "نوبتها بر اساس ارائهدهنده",
|
||||||
"chartAppointmentsByProviderSubtitle": "امروز",
|
"chartAppointmentsByProviderSubtitle": "امروز",
|
||||||
"chartTreatmentMixTitle": "ترکیب درمانها",
|
"chartTreatmentMixTitle": "ترکیب درمانها",
|
||||||
@@ -228,8 +235,19 @@
|
|||||||
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
||||||
"chartCaseCompletionPercent": "{percent}٪",
|
"chartCaseCompletionPercent": "{percent}٪",
|
||||||
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||||
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
|
"chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان",
|
||||||
"chartTasksByStepSubtitle": "در حال انجام",
|
"chartTreatmentPlanCompletionSubtitle": "همه نوبتها",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||||
|
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||||
|
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||||
|
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||||
|
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||||
|
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
||||||
|
"chartCasePartnersSentLegend": "ارسالشده",
|
||||||
|
"chartCasePartnersOpenLegend": "در حال انجام",
|
||||||
|
"chartEfficiencyReportTitle": "گزارش کارایی",
|
||||||
|
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
||||||
|
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
||||||
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
||||||
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
||||||
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
||||||
|
|||||||
@@ -208,8 +208,17 @@
|
|||||||
"widgetImportantTasks": "Belangrijke taken",
|
"widgetImportantTasks": "Belangrijke taken",
|
||||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||||
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||||
"widgetSeats": "Zitplaatsgebruik",
|
|
||||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
"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",
|
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
|
||||||
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
||||||
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
||||||
@@ -218,8 +227,6 @@
|
|||||||
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
|
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
|
||||||
"chartLabTaskCompletedLegend": "Voltooid",
|
"chartLabTaskCompletedLegend": "Voltooid",
|
||||||
"chartLabTaskReceivedLegend": "Ontvangen",
|
"chartLabTaskReceivedLegend": "Ontvangen",
|
||||||
"chartProsthesisMixTitle": "Lopende taken per prothese",
|
|
||||||
"chartProsthesisMixSubtitle": "Huidige werklastmix",
|
|
||||||
"chartAppointmentsByProviderTitle": "Afspraken per behandelaar",
|
"chartAppointmentsByProviderTitle": "Afspraken per behandelaar",
|
||||||
"chartAppointmentsByProviderSubtitle": "Vandaag",
|
"chartAppointmentsByProviderSubtitle": "Vandaag",
|
||||||
"chartTreatmentMixTitle": "Behandelingsmix",
|
"chartTreatmentMixTitle": "Behandelingsmix",
|
||||||
@@ -228,8 +235,19 @@
|
|||||||
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
||||||
"chartCaseCompletionPercent": "{percent}%",
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
"chartCaseCompletionTasks": "Taken voltooid",
|
"chartCaseCompletionTasks": "Taken voltooid",
|
||||||
"chartTasksByStepTitle": "Taken per workflowstap",
|
"chartTreatmentPlanCompletionTitle": "Behandelplanvoltooiing",
|
||||||
"chartTasksByStepSubtitle": "Nu in uitvoering",
|
"chartTreatmentPlanCompletionSubtitle": "Alle afspraken",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
||||||
|
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
||||||
|
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
||||||
|
"chartCasePartnersClinicTitle": "Cases per lab",
|
||||||
|
"chartCasePartnersLabTitle": "Cases per kliniek",
|
||||||
|
"chartCasePartnersSubtitle": "Afgelopen 30 dagen",
|
||||||
|
"chartCasePartnersSentLegend": "Verzonden",
|
||||||
|
"chartCasePartnersOpenLegend": "In uitvoering",
|
||||||
|
"chartEfficiencyReportTitle": "Efficiëntierapport",
|
||||||
|
"chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen",
|
||||||
|
"chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen",
|
||||||
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
||||||
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
|
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
|
||||||
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
|
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
||||||
|
if (!canManageAppointments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isViewingPastDay) {
|
if (isViewingPastDay) {
|
||||||
toast.showInfo(t('infoPastViewOnly'));
|
toast.showInfo(t('infoPastViewOnly'));
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +208,9 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||||
|
if (!canManageAppointments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isViewingPastDay) {
|
if (isViewingPastDay) {
|
||||||
toast.showInfo(t('infoPastViewOnly'));
|
toast.showInfo(t('infoPastViewOnly'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,18 +4,8 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
|
||||||
canAccessAppointmentsSection,
|
|
||||||
canViewAppointmentsTab,
|
|
||||||
canViewCases,
|
|
||||||
canViewLabCasesOrTasks,
|
|
||||||
canViewTasks,
|
|
||||||
canViewTreatment,
|
|
||||||
} from '@/components/shared/permissions';
|
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
|
import { TodayDashboard } from '@/components/today/TodayDashboard';
|
||||||
import { TodayChartsSection } from '@/components/today/TodayChartsSection';
|
|
||||||
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
|
||||||
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
||||||
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
||||||
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
|
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
|
||||||
@@ -27,29 +17,10 @@ export default function TodayPage() {
|
|||||||
const orgId = currentOrganization?.id;
|
const orgId = currentOrganization?.id;
|
||||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||||
|
|
||||||
const showNoSubscriptionNotice =
|
const showNoSubscriptionNotice = useMemo(
|
||||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
|
||||||
|
[currentOrganization],
|
||||||
const showUpcoming =
|
);
|
||||||
currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization);
|
|
||||||
const showCharts = useMemo(() => {
|
|
||||||
const orgType = currentOrganization?.type;
|
|
||||||
if (!orgType) return false;
|
|
||||||
|
|
||||||
if (orgType === 'CLINIC') {
|
|
||||||
return (
|
|
||||||
canAccessAppointmentsSection(currentOrganization) ||
|
|
||||||
canViewAppointmentsTab(currentOrganization) ||
|
|
||||||
canViewTreatment(currentOrganization)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
canViewCases(currentOrganization) ||
|
|
||||||
canViewTasks(currentOrganization) ||
|
|
||||||
canViewLabCasesOrTasks(currentOrganization)
|
|
||||||
);
|
|
||||||
}, [currentOrganization]);
|
|
||||||
|
|
||||||
const sectionErrorMessage = t('sectionLoadError');
|
const sectionErrorMessage = t('sectionLoadError');
|
||||||
|
|
||||||
@@ -93,47 +64,16 @@ export default function TodayPage() {
|
|||||||
<TodayWidgetErrorBoundary
|
<TodayWidgetErrorBoundary
|
||||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||||
>
|
>
|
||||||
<TodayKpiGrid
|
<TodayDashboard
|
||||||
widgets={data?.widgets ?? {}}
|
widgets={data?.widgets ?? {}}
|
||||||
|
charts={data?.charts ?? {}}
|
||||||
|
actions={data?.actions ?? {}}
|
||||||
|
subscription={data?.subscription}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
isInitialLoad={isInitialLoad}
|
isInitialLoad={isInitialLoad}
|
||||||
hasError={Boolean(error)}
|
hasError={Boolean(error)}
|
||||||
/>
|
/>
|
||||||
</TodayWidgetErrorBoundary>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,7 @@ export function canViewStaff(org: Organization | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns).
|
* Create/delete/book slots: owners or staff with TAB_APPOINTMENTS_EDIT only.
|
||||||
* Aligns with backend appointment mutations.
|
|
||||||
*/
|
*/
|
||||||
export function canEditAppointments(org: Organization | null): boolean {
|
export function canEditAppointments(org: Organization | null): boolean {
|
||||||
if (!org) {
|
if (!org) {
|
||||||
@@ -131,29 +130,12 @@ export function canEditAppointments(org: Organization | null): boolean {
|
|||||||
if (org.isOwner) {
|
if (org.isOwner) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return hasPermission(org, 'TAB_APPOINTMENTS_EDIT');
|
||||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
|
|
||||||
hasPermission(org, 'TAB_TREATMENT_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 {
|
export function canAccessAppointmentsSection(org: Organization | null): boolean {
|
||||||
if (!org) {
|
return canViewAppointmentsTab(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')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Treatment composer, scheduling columns, and saving clinical workflows */
|
/** Treatment composer, scheduling columns, and saving clinical workflows */
|
||||||
@@ -164,6 +146,14 @@ export function canEditTreatment(org: Organization | null): boolean {
|
|||||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff treatment editors only — personal schedule Today gadgets (not owners). */
|
||||||
|
export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
if (org.type !== 'CLINIC') return false;
|
||||||
|
if (org.isOwner) return false;
|
||||||
|
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||||
|
}
|
||||||
|
|
||||||
/** View treatment workspace (read-only or edit) */
|
/** View treatment workspace (read-only or edit) */
|
||||||
export function canViewTreatment(org: Organization | null): boolean {
|
export function canViewTreatment(org: Organization | null): boolean {
|
||||||
if (!org) return false;
|
if (!org) return false;
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ interface ChartCardProps {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/**
|
||||||
|
* Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel).
|
||||||
|
* Chart column is independent and vertically centered.
|
||||||
|
*/
|
||||||
|
sidePanelLayout?: boolean;
|
||||||
|
chartPanel?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCardHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: Pick<ChartCardProps, 'title' | 'subtitle'>) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3 shrink-0">
|
||||||
|
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||||
|
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChartCard({
|
export function ChartCard({
|
||||||
@@ -18,26 +36,50 @@ export function ChartCard({
|
|||||||
emptyMessage,
|
emptyMessage,
|
||||||
isEmpty = false,
|
isEmpty = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
sidePanelLayout = false,
|
||||||
|
chartPanel,
|
||||||
}: ChartCardProps) {
|
}: ChartCardProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <ChartCardSkeleton />;
|
return <ChartCardSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (sidePanelLayout) {
|
||||||
<Card className="min-h-[280px] flex flex-col">
|
return (
|
||||||
<div className="mb-4">
|
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
|
||||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||||
{subtitle ? (
|
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||||
<p className="text-xs text-text-muted mt-1">{subtitle}</p>
|
{isEmpty ? (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
|
||||||
|
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEmpty && chartPanel ? (
|
||||||
|
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
|
||||||
|
<div className="aspect-square h-full max-h-full w-full max-w-full">
|
||||||
|
{chartPanel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||||
|
|
||||||
{isEmpty ? (
|
{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>
|
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import type { KpiCardColor } from '@/components/today/widget-registry';
|
import type { KpiCardColor } from '@/components/today/widget-registry';
|
||||||
@@ -20,6 +22,7 @@ interface KpiCardProps {
|
|||||||
color?: KpiCardColor;
|
color?: KpiCardColor;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KpiCard({
|
export function KpiCard({
|
||||||
@@ -30,10 +33,11 @@ export function KpiCard({
|
|||||||
color = 'default',
|
color = 'default',
|
||||||
loading = false,
|
loading = false,
|
||||||
href,
|
href,
|
||||||
|
className = '',
|
||||||
}: KpiCardProps) {
|
}: KpiCardProps) {
|
||||||
const card = (
|
const card = (
|
||||||
<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">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm font-medium">{title}</p>
|
<p className="text-sm font-medium">{title}</p>
|
||||||
@@ -57,7 +61,10 @@ export function KpiCard({
|
|||||||
|
|
||||||
if (href && !loading) {
|
if (href && !loading) {
|
||||||
return (
|
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}
|
{card}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
import type { TodayChartBucket } from '@/types/today';
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
import {
|
import {
|
||||||
TODAY_CHART_AXIS_COLOR,
|
TODAY_CHART_AXIS_COLOR,
|
||||||
@@ -19,48 +20,62 @@ import {
|
|||||||
|
|
||||||
interface TodayAreaChartProps {
|
interface TodayAreaChartProps {
|
||||||
data: TodayChartBucket[];
|
data: TodayChartBucket[];
|
||||||
|
color?: string;
|
||||||
|
gradientId?: string;
|
||||||
|
showXAxis?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayAreaChart({ data }: TodayAreaChartProps) {
|
export function TodayAreaChart({
|
||||||
|
data,
|
||||||
|
color = TODAY_CHART_PRIMARY_COLOR,
|
||||||
|
gradientId = 'todayAreaFill',
|
||||||
|
showXAxis = true,
|
||||||
|
}: TodayAreaChartProps) {
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<TodayChartFrame>
|
||||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<defs>
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
|
||||||
<linearGradient id="todayAreaFill" x1="0" y1="0" x2="0" y2="1">
|
<defs>
|
||||||
<stop offset="0%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.45} />
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="100%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.05} />
|
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
|
||||||
</linearGradient>
|
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
||||||
</defs>
|
</linearGradient>
|
||||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
</defs>
|
||||||
<XAxis
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
dataKey="label"
|
{showXAxis ? (
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
<XAxis
|
||||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
dataKey="label"
|
||||||
tickLine={false}
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
interval={1}
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
/>
|
tickLine={false}
|
||||||
<YAxis
|
interval={1}
|
||||||
allowDecimals={false}
|
/>
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
) : (
|
||||||
axisLine={false}
|
<XAxis dataKey="label" hide />
|
||||||
tickLine={false}
|
)}
|
||||||
width={32}
|
<YAxis
|
||||||
/>
|
allowDecimals={false}
|
||||||
<Tooltip
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
axisLine={false}
|
||||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
tickLine={false}
|
||||||
labelFormatter={(label) => String(label)}
|
width={32}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Tooltip
|
||||||
type="monotone"
|
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||||
dataKey="count"
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
stroke={TODAY_CHART_PRIMARY_COLOR}
|
labelFormatter={(label) => String(label)}
|
||||||
strokeWidth={2}
|
/>
|
||||||
fill="url(#todayAreaFill)"
|
<Area
|
||||||
dot={{ r: 3, fill: TODAY_CHART_PRIMARY_COLOR, strokeWidth: 0 }}
|
type="monotone"
|
||||||
activeDot={{ r: 5, fill: TODAY_CHART_PRIMARY_COLOR }}
|
dataKey="count"
|
||||||
/>
|
stroke={color}
|
||||||
</AreaChart>
|
strokeWidth={2}
|
||||||
</ResponsiveContainer>
|
fill={`url(#${gradientId})`}
|
||||||
|
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5, fill: color }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TodayChartFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { TodayChartBucket } from '@/types/today';
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
import {
|
import {
|
||||||
TODAY_CHART_AXIS_COLOR,
|
TODAY_CHART_AXIS_COLOR,
|
||||||
TODAY_CHART_COLORS,
|
TODAY_CHART_COLORS,
|
||||||
@@ -31,11 +32,12 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<TodayChartFrame>
|
||||||
<BarChart
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={chartData}
|
<BarChart
|
||||||
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
data={chartData}
|
||||||
>
|
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
||||||
|
>
|
||||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="shortLabel"
|
dataKey="shortLabel"
|
||||||
@@ -105,7 +107,8 @@ export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
|||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</TodayChartFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
|
import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme';
|
||||||
|
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||||
|
import type { TodayCompletionGauge } from '@/types/today';
|
||||||
|
|
||||||
|
export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
percentLabel: string;
|
||||||
|
ratioLabel: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayCompletionGaugeKpiCard({
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
percent,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
percentLabel,
|
||||||
|
ratioLabel,
|
||||||
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
}: TodayCompletionGaugeKpiCardProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
|
||||||
|
>
|
||||||
|
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-text-muted">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 !text-current" aria-hidden />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<div className="w-[58%] min-w-0">
|
||||||
|
<TodayRadialGaugeChart
|
||||||
|
size="sm"
|
||||||
|
compactClassName="h-[120px]"
|
||||||
|
innerRadius="72%"
|
||||||
|
compactBarSize={8}
|
||||||
|
percent={total > 0 ? percent : 0}
|
||||||
|
completed={completed}
|
||||||
|
total={total}
|
||||||
|
percentLabel={total > 0 ? percentLabel : '—'}
|
||||||
|
tasksLabel={ratioLabel}
|
||||||
|
fillColor={TODAY_CHART_COMPLETED_COLOR}
|
||||||
|
showRatio={total > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
686
frontend/src/components/today/TodayDashboard.tsx
Normal file
686
frontend/src/components/today/TodayDashboard.tsx
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import {
|
||||||
|
canEditCases,
|
||||||
|
canEditTreatment,
|
||||||
|
canViewAppointmentsTab,
|
||||||
|
canViewCases,
|
||||||
|
canViewLabCasesOrTasks,
|
||||||
|
canViewMyAppointmentsWeekChart,
|
||||||
|
canViewTasks,
|
||||||
|
canViewTreatment,
|
||||||
|
} from '@/components/shared/permissions';
|
||||||
|
import { KpiCard } from '@/components/today/KpiCard';
|
||||||
|
import { ChartCard } from '@/components/today/ChartCard';
|
||||||
|
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||||
|
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||||
|
import {
|
||||||
|
mapWeekChartBuckets,
|
||||||
|
useTodayDayLabelFormatter,
|
||||||
|
} from '@/components/today/chart-day-labels';
|
||||||
|
import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid';
|
||||||
|
import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart';
|
||||||
|
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||||
|
import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart';
|
||||||
|
import { Package, Stethoscope, type LucideIcon } from 'lucide-react';
|
||||||
|
import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard';
|
||||||
|
import {
|
||||||
|
mapLabTaskActivityChartData,
|
||||||
|
TodayLabTaskActivityChart,
|
||||||
|
} from '@/components/today/TodayLabTaskActivityChart';
|
||||||
|
import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard';
|
||||||
|
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
||||||
|
import {
|
||||||
|
ChartCardSkeleton,
|
||||||
|
KpiCardSkeleton,
|
||||||
|
ListRowSkeleton,
|
||||||
|
} from '@/components/today/TodaySkeleton';
|
||||||
|
import {
|
||||||
|
TODAY_DASHBOARD_LAYOUT,
|
||||||
|
type TodayDashboardCell,
|
||||||
|
} from '@/components/today/today-dashboard-layout';
|
||||||
|
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||||
|
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||||
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import type {
|
||||||
|
TodayCompletionGauge,
|
||||||
|
TodaySubscriptionSnapshot,
|
||||||
|
TodaySummaryActions,
|
||||||
|
TodaySummaryCharts,
|
||||||
|
TodaySummaryWidgets,
|
||||||
|
} from '@/types/today';
|
||||||
|
|
||||||
|
interface TodayDashboardProps {
|
||||||
|
widgets: TodaySummaryWidgets;
|
||||||
|
charts: TodaySummaryCharts;
|
||||||
|
actions: TodaySummaryActions;
|
||||||
|
subscription?: TodaySubscriptionSnapshot;
|
||||||
|
loading?: boolean;
|
||||||
|
isInitialLoad?: boolean;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayDashboard({
|
||||||
|
widgets,
|
||||||
|
charts,
|
||||||
|
actions,
|
||||||
|
subscription,
|
||||||
|
loading = false,
|
||||||
|
isInitialLoad = false,
|
||||||
|
hasError = false,
|
||||||
|
}: TodayDashboardProps) {
|
||||||
|
const t = useTranslations('today');
|
||||||
|
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||||
|
const { currentOrganization } = useAuth();
|
||||||
|
const orgType = currentOrganization?.type;
|
||||||
|
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||||
|
|
||||||
|
const showUpcoming =
|
||||||
|
orgType === 'CLINIC' &&
|
||||||
|
currentOrganization &&
|
||||||
|
canViewMyAppointmentsWeekChart(currentOrganization);
|
||||||
|
|
||||||
|
const showCasePartnersChart = Boolean(
|
||||||
|
currentOrganization &&
|
||||||
|
((orgType === 'CLINIC' && canEditTreatment(currentOrganization)) ||
|
||||||
|
(orgType === 'LAB' && canEditCases(currentOrganization))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCharts = useMemo(() => {
|
||||||
|
if (!orgType || !currentOrganization) return false;
|
||||||
|
if (orgType === 'CLINIC') {
|
||||||
|
return (
|
||||||
|
canViewAppointmentsTab(currentOrganization) ||
|
||||||
|
canViewTreatment(currentOrganization)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
canViewCases(currentOrganization) ||
|
||||||
|
canViewTasks(currentOrganization) ||
|
||||||
|
canViewLabCasesOrTasks(currentOrganization)
|
||||||
|
);
|
||||||
|
}, [currentOrganization, orgType]);
|
||||||
|
|
||||||
|
const kpiDefinitions = isInitialLoad
|
||||||
|
? getEligibleTodayKpis(currentOrganization)
|
||||||
|
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||||
|
|
||||||
|
const showSubscriptionCard = isOwner && (isInitialLoad || Boolean(subscription));
|
||||||
|
|
||||||
|
const showCaseCompletionCard =
|
||||||
|
orgType === 'LAB' &&
|
||||||
|
Boolean(currentOrganization && canViewCases(currentOrganization)) &&
|
||||||
|
(isInitialLoad || charts.caseCompletion !== undefined);
|
||||||
|
|
||||||
|
const showTreatmentPlanCompletionCard =
|
||||||
|
orgType === 'CLINIC' &&
|
||||||
|
Boolean(currentOrganization && canEditTreatment(currentOrganization)) &&
|
||||||
|
(isInitialLoad || charts.treatmentPlanCompletion !== undefined);
|
||||||
|
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
if (isInitialLoad) {
|
||||||
|
return buildSkeletonCells({
|
||||||
|
kpiDefinitions,
|
||||||
|
showSubscriptionCard,
|
||||||
|
showCaseCompletionCard,
|
||||||
|
showTreatmentPlanCompletionCard,
|
||||||
|
showUpcoming: Boolean(showUpcoming),
|
||||||
|
showCharts,
|
||||||
|
orgType,
|
||||||
|
isOwner,
|
||||||
|
showMyAppointmentsWeekChart: Boolean(
|
||||||
|
currentOrganization &&
|
||||||
|
canViewMyAppointmentsWeekChart(currentOrganization),
|
||||||
|
),
|
||||||
|
showCasePartnersChart,
|
||||||
|
charts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDashboardCells({
|
||||||
|
t,
|
||||||
|
dayLabelFormatter,
|
||||||
|
widgets,
|
||||||
|
charts,
|
||||||
|
actions,
|
||||||
|
subscription,
|
||||||
|
kpiDefinitions,
|
||||||
|
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||||
|
showCaseCompletionCard:
|
||||||
|
showCaseCompletionCard && charts.caseCompletion !== undefined,
|
||||||
|
showTreatmentPlanCompletionCard:
|
||||||
|
showTreatmentPlanCompletionCard &&
|
||||||
|
charts.treatmentPlanCompletion !== undefined,
|
||||||
|
showUpcoming: Boolean(showUpcoming),
|
||||||
|
showCharts,
|
||||||
|
orgType,
|
||||||
|
isOwner,
|
||||||
|
currentOrganization,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
isInitialLoad,
|
||||||
|
kpiDefinitions,
|
||||||
|
showSubscriptionCard,
|
||||||
|
showCaseCompletionCard,
|
||||||
|
showTreatmentPlanCompletionCard,
|
||||||
|
showUpcoming,
|
||||||
|
showCharts,
|
||||||
|
orgType,
|
||||||
|
isOwner,
|
||||||
|
charts,
|
||||||
|
t,
|
||||||
|
dayLabelFormatter,
|
||||||
|
widgets,
|
||||||
|
actions,
|
||||||
|
subscription,
|
||||||
|
currentOrganization,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (hasError && !loading && cells.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && !hasError && cells.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
|
||||||
|
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TodayDashboardGrid cells={cells} loading={loading} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkeletonCells(options: {
|
||||||
|
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
|
||||||
|
showSubscriptionCard: boolean;
|
||||||
|
showCaseCompletionCard: boolean;
|
||||||
|
showTreatmentPlanCompletionCard: boolean;
|
||||||
|
showUpcoming: boolean;
|
||||||
|
showCharts: boolean;
|
||||||
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
|
isOwner: boolean;
|
||||||
|
showMyAppointmentsWeekChart: boolean;
|
||||||
|
showCasePartnersChart: boolean;
|
||||||
|
charts: TodaySummaryCharts;
|
||||||
|
}): TodayDashboardCell[] {
|
||||||
|
const cells: TodayDashboardCell[] = [];
|
||||||
|
|
||||||
|
if (options.showCharts) {
|
||||||
|
const chartCount = countVisibleCharts(
|
||||||
|
options.charts,
|
||||||
|
options.orgType,
|
||||||
|
options.isOwner,
|
||||||
|
options.showMyAppointmentsWeekChart,
|
||||||
|
options.showCasePartnersChart,
|
||||||
|
);
|
||||||
|
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
|
||||||
|
cells.push({
|
||||||
|
id: `chart-skeleton-${index}`,
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.chart,
|
||||||
|
content: <ChartCardSkeleton />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showUpcoming) {
|
||||||
|
cells.push({
|
||||||
|
id: 'upcoming-skeleton',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
|
||||||
|
content: (
|
||||||
|
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-3">
|
||||||
|
<div className="mb-2 space-y-1.5">
|
||||||
|
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
||||||
|
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1].map((key) => (
|
||||||
|
<ListRowSkeleton key={key} compact />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showSubscriptionCard) {
|
||||||
|
cells.push({
|
||||||
|
id: 'subscription-skeleton',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: <KpiCardSkeleton tall />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showCaseCompletionCard) {
|
||||||
|
cells.push({
|
||||||
|
id: 'case-completion-skeleton',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: <KpiCardSkeleton tall />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showTreatmentPlanCompletionCard) {
|
||||||
|
cells.push({
|
||||||
|
id: 'treatment-plan-completion-skeleton',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: <KpiCardSkeleton tall />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const definition of options.kpiDefinitions) {
|
||||||
|
cells.push({
|
||||||
|
id: `kpi-skeleton-${definition.key}`,
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.kpi,
|
||||||
|
content: <KpiCardSkeleton />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDashboardCells(options: {
|
||||||
|
t: ReturnType<typeof useTranslations<'today'>>;
|
||||||
|
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||||
|
widgets: TodaySummaryWidgets;
|
||||||
|
charts: TodaySummaryCharts;
|
||||||
|
actions: TodaySummaryActions;
|
||||||
|
subscription?: TodaySubscriptionSnapshot;
|
||||||
|
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
||||||
|
showSubscriptionCard: boolean;
|
||||||
|
showCaseCompletionCard: boolean;
|
||||||
|
showTreatmentPlanCompletionCard: boolean;
|
||||||
|
showUpcoming: boolean;
|
||||||
|
showCharts: boolean;
|
||||||
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
|
isOwner: boolean;
|
||||||
|
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||||
|
}): TodayDashboardCell[] {
|
||||||
|
const cells: TodayDashboardCell[] = [];
|
||||||
|
|
||||||
|
if (options.showCharts) {
|
||||||
|
cells.push(
|
||||||
|
...buildChartCells({
|
||||||
|
t: options.t,
|
||||||
|
charts: options.charts,
|
||||||
|
orgType: options.orgType,
|
||||||
|
isOwner: options.isOwner,
|
||||||
|
showMyAppointmentsWeekChart: Boolean(
|
||||||
|
options.currentOrganization &&
|
||||||
|
canViewMyAppointmentsWeekChart(options.currentOrganization),
|
||||||
|
),
|
||||||
|
showCasePartnersChart:
|
||||||
|
Boolean(options.currentOrganization) &&
|
||||||
|
((options.orgType === 'CLINIC' &&
|
||||||
|
canEditTreatment(options.currentOrganization)) ||
|
||||||
|
(options.orgType === 'LAB' &&
|
||||||
|
canEditCases(options.currentOrganization))),
|
||||||
|
dayLabelFormatter: options.dayLabelFormatter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showUpcoming) {
|
||||||
|
cells.push({
|
||||||
|
id: 'upcoming-appointments',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
|
||||||
|
content: (
|
||||||
|
<TodayUpcomingAppointments actions={options.actions} loading={false} isInitialLoad={false} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showSubscriptionCard && options.subscription) {
|
||||||
|
cells.push({
|
||||||
|
id: 'subscription',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: <TodaySubscriptionKpiCard subscription={options.subscription} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) {
|
||||||
|
pushCompletionGaugeCell(cells, {
|
||||||
|
id: 'case-completion',
|
||||||
|
gauge: options.charts.caseCompletion,
|
||||||
|
title: options.t('chartCaseCompletionTitle'),
|
||||||
|
subtitle: options.t('chartCaseCompletionSubtitle'),
|
||||||
|
percentLabel: options.t('chartCaseCompletionPercent', {
|
||||||
|
percent: options.charts.caseCompletion.percent,
|
||||||
|
}),
|
||||||
|
ratioLabel: options.t('chartCaseCompletionTasks'),
|
||||||
|
href: '/cases',
|
||||||
|
icon: Package,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.showTreatmentPlanCompletionCard &&
|
||||||
|
options.charts.treatmentPlanCompletion !== undefined
|
||||||
|
) {
|
||||||
|
pushCompletionGaugeCell(cells, {
|
||||||
|
id: 'treatment-plan-completion',
|
||||||
|
gauge: options.charts.treatmentPlanCompletion,
|
||||||
|
title: options.t('chartTreatmentPlanCompletionTitle'),
|
||||||
|
subtitle: options.t('chartTreatmentPlanCompletionSubtitle'),
|
||||||
|
percentLabel: options.t('chartCaseCompletionPercent', {
|
||||||
|
percent: options.charts.treatmentPlanCompletion.percent,
|
||||||
|
}),
|
||||||
|
ratioLabel: options.t('chartTreatmentPlanCompletionRatio'),
|
||||||
|
href: '/appointments',
|
||||||
|
icon: Stethoscope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const definition of options.kpiDefinitions) {
|
||||||
|
const value = definition.formatValue(options.widgets) ?? '—';
|
||||||
|
const subtitle = definition.formatSubtitle?.(options.widgets);
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
id: `kpi-${definition.key}`,
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.kpi,
|
||||||
|
content: (
|
||||||
|
<KpiCard
|
||||||
|
title={options.t(definition.titleKey)}
|
||||||
|
value={value}
|
||||||
|
subtitle={subtitle}
|
||||||
|
icon={definition.icon}
|
||||||
|
color={definition.color}
|
||||||
|
href={definition.href}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartCells(options: {
|
||||||
|
t: ReturnType<typeof useTranslations<'today'>>;
|
||||||
|
charts: TodaySummaryCharts;
|
||||||
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
|
isOwner: boolean;
|
||||||
|
showMyAppointmentsWeekChart: boolean;
|
||||||
|
showCasePartnersChart: boolean;
|
||||||
|
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||||
|
}): TodayDashboardCell[] {
|
||||||
|
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||||
|
const cells: TodayDashboardCell[] = [];
|
||||||
|
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
|
||||||
|
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
|
||||||
|
|
||||||
|
const appointmentsWeekAllData = mapWeekChartBuckets(
|
||||||
|
charts.appointmentsWeekAll ?? [],
|
||||||
|
options.dayLabelFormatter,
|
||||||
|
);
|
||||||
|
const appointmentsWeekMineData = mapWeekChartBuckets(
|
||||||
|
charts.appointmentsWeekMine ?? [],
|
||||||
|
options.dayLabelFormatter,
|
||||||
|
);
|
||||||
|
const labTaskActivityData = mapWeekChartBuckets(
|
||||||
|
charts.labTaskActivityWeek ?? [],
|
||||||
|
options.dayLabelFormatter,
|
||||||
|
);
|
||||||
|
const labTaskActivityChartData = mapLabTaskActivityChartData(labTaskActivityData);
|
||||||
|
|
||||||
|
if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-appointments-week-all',
|
||||||
|
layout: areaChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsWeekAllTitle')}
|
||||||
|
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||||
|
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
orgType === 'CLINIC' &&
|
||||||
|
showMyAppointmentsWeekChart &&
|
||||||
|
charts.appointmentsWeekMine !== undefined
|
||||||
|
) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-appointments-week-mine',
|
||||||
|
layout: areaChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsWeekMineTitle')}
|
||||||
|
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||||
|
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayAreaChart data={appointmentsWeekMineData} />
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-lab-task-activity',
|
||||||
|
layout: areaChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartLabTaskActivityTitle')}
|
||||||
|
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||||
|
isEmpty={labTaskActivityData.every(
|
||||||
|
(row) => row.completed === 0 && row.received === 0,
|
||||||
|
)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayLabTaskActivityChart
|
||||||
|
data={labTaskActivityChartData}
|
||||||
|
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||||
|
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const efficiencyReportData = charts.efficiencyReport ?? [];
|
||||||
|
if (
|
||||||
|
isOwner &&
|
||||||
|
charts.efficiencyReport !== undefined &&
|
||||||
|
efficiencyReportData.length >= 2
|
||||||
|
) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-efficiency-report',
|
||||||
|
layout: areaChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartEfficiencyReportTitle')}
|
||||||
|
subtitle={
|
||||||
|
orgType === 'CLINIC'
|
||||||
|
? t('chartEfficiencyReportSubtitleClinic')
|
||||||
|
: t('chartEfficiencyReportSubtitleLab')
|
||||||
|
}
|
||||||
|
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
sidePanelLayout
|
||||||
|
chartPanel={
|
||||||
|
<TodayDonutChart
|
||||||
|
data={efficiencyReportData}
|
||||||
|
labelForCode={(code) =>
|
||||||
|
efficiencyReportData.find((row) => row.code === code)?.label ?? code
|
||||||
|
}
|
||||||
|
variant="pie"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TodayDonutChartLegend
|
||||||
|
data={efficiencyReportData}
|
||||||
|
labelForCode={(code) =>
|
||||||
|
efficiencyReportData.find((row) => row.code === code)?.label ?? code
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||||
|
if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-appointments-by-provider',
|
||||||
|
layout: barChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsByProviderTitle')}
|
||||||
|
subtitle={t('chartAppointmentsByProviderSubtitle')}
|
||||||
|
isEmpty={appointmentsByProviderData.length === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayHorizontalBarChart data={appointmentsByProviderData} />
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const treatmentData = charts.treatmentMixWeek ?? [];
|
||||||
|
if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-treatment-mix',
|
||||||
|
layout: barChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartTreatmentMixTitle')}
|
||||||
|
subtitle={t('chartTreatmentMixSubtitle')}
|
||||||
|
isEmpty={treatmentData.length === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayBarChart
|
||||||
|
data={treatmentData}
|
||||||
|
colorForCode={(code, index) => treatmentTypeColor(code, index)}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksByProsthesisData = charts.tasksByProsthesis ?? [];
|
||||||
|
if (orgType === 'LAB' && charts.tasksByProsthesis !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-tasks-by-prosthesis',
|
||||||
|
layout: barChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartTasksByProsthesisTitle')}
|
||||||
|
subtitle={t('chartTasksByProsthesisSubtitle')}
|
||||||
|
isEmpty={tasksByProsthesisData.length === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayBarChart
|
||||||
|
data={tasksByProsthesisData}
|
||||||
|
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const casePartnersData = charts.casePartnersMonth ?? [];
|
||||||
|
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
|
||||||
|
cells.push({
|
||||||
|
id: 'chart-case-partners-month',
|
||||||
|
layout: barChart,
|
||||||
|
content: (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
orgType === 'CLINIC'
|
||||||
|
? t('chartCasePartnersClinicTitle')
|
||||||
|
: t('chartCasePartnersLabTitle')
|
||||||
|
}
|
||||||
|
subtitle={t('chartCasePartnersSubtitle')}
|
||||||
|
isEmpty={casePartnersData.every(
|
||||||
|
(row) => row.completed === 0 && row.pending === 0,
|
||||||
|
)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayPartnerCasesStackedBarChart
|
||||||
|
data={casePartnersData}
|
||||||
|
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||||
|
pendingLabel={
|
||||||
|
orgType === 'CLINIC'
|
||||||
|
? t('chartCasePartnersSentLegend')
|
||||||
|
: t('chartCasePartnersOpenLegend')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVisibleCharts(
|
||||||
|
charts: TodaySummaryCharts,
|
||||||
|
orgType?: 'CLINIC' | 'LAB',
|
||||||
|
isOwner = false,
|
||||||
|
showMyAppointmentsWeekChart = false,
|
||||||
|
showCasePartnersChart = false,
|
||||||
|
): number {
|
||||||
|
let count = 0;
|
||||||
|
if (orgType === 'CLINIC') {
|
||||||
|
count += charts.appointmentsWeekAll !== undefined ? 1 : 0;
|
||||||
|
count +=
|
||||||
|
showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0;
|
||||||
|
count += charts.appointmentsByProvider !== undefined ? 1 : 0;
|
||||||
|
count += charts.treatmentMixWeek !== undefined ? 1 : 0;
|
||||||
|
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (orgType === 'LAB') {
|
||||||
|
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||||
|
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
||||||
|
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isOwner &&
|
||||||
|
charts.efficiencyReport !== undefined &&
|
||||||
|
(charts.efficiencyReport?.length ?? 0) >= 2
|
||||||
|
) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushCompletionGaugeCell(
|
||||||
|
cells: TodayDashboardCell[],
|
||||||
|
options: {
|
||||||
|
id: string;
|
||||||
|
gauge: TodayCompletionGauge;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
percentLabel: string;
|
||||||
|
ratioLabel: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
cells.push({
|
||||||
|
id: options.id,
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: (
|
||||||
|
<TodayCompletionGaugeKpiCard
|
||||||
|
completed={options.gauge.completed}
|
||||||
|
total={options.gauge.total}
|
||||||
|
percent={options.gauge.percent}
|
||||||
|
title={options.title}
|
||||||
|
subtitle={options.subtitle}
|
||||||
|
percentLabel={options.percentLabel}
|
||||||
|
ratioLabel={options.ratioLabel}
|
||||||
|
href={options.href}
|
||||||
|
icon={options.icon}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
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,20 +3,102 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
import type { TodayChartBucket } from '@/types/today';
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
import {
|
import {
|
||||||
TODAY_CHART_COLORS,
|
chartRankColor,
|
||||||
TODAY_CHART_TOOLTIP_STYLE,
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
} from '@/components/today/chart-theme';
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
interface TodayDonutChartProps {
|
interface TodayDonutChartBaseProps {
|
||||||
data: TodayChartBucket[];
|
data: TodayChartBucket[];
|
||||||
labelForCode: (code: string) => string;
|
labelForCode: (code: string) => string;
|
||||||
colorForCode?: (code: string, index: number) => string;
|
colorForCode?: (code: string, index: number) => string;
|
||||||
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TodayDonutChartProps extends TodayDonutChartBaseProps {
|
||||||
variant?: 'donut' | 'pie';
|
variant?: 'donut' | 'pie';
|
||||||
|
/** Inline legend + chart row (legacy). Prefer TodayDonutChartLegend + sidePanelLayout. */
|
||||||
sideLegend?: boolean;
|
sideLegend?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useDonutChartModel({
|
||||||
|
data,
|
||||||
|
labelForCode,
|
||||||
|
colorForCode,
|
||||||
|
swatchStyleForCode,
|
||||||
|
}: TodayDonutChartBaseProps) {
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
displayLabel: labelForCode(item.code),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolveColor = (code: string, index: number) =>
|
||||||
|
colorForCode?.(code, index) ?? chartRankColor(index);
|
||||||
|
|
||||||
|
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
||||||
|
swatchStyleForCode?.(code, index) ?? {
|
||||||
|
backgroundColor: resolveColor(code, index),
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.18)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chartData, resolveColor, resolveSwatchStyle };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayDonutChartLegend({
|
||||||
|
data,
|
||||||
|
labelForCode,
|
||||||
|
colorForCode,
|
||||||
|
swatchStyleForCode,
|
||||||
|
}: TodayDonutChartBaseProps) {
|
||||||
|
const { chartData, resolveSwatchStyle } = useDonutChartModel({
|
||||||
|
data,
|
||||||
|
labelForCode,
|
||||||
|
colorForCode,
|
||||||
|
swatchStyleForCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-start overflow-hidden">
|
||||||
|
<div className="flex max-h-full min-w-0 flex-col items-start gap-1.5 overflow-y-auto">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<span key={entry.code} className={rowClass}>
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-sm border"
|
||||||
|
style={resolveSwatchStyle(entry.code, index)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2 flex min-w-0 flex-col items-start gap-1.5 overflow-hidden">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
||||||
|
>
|
||||||
|
{entry.displayLabel}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
||||||
|
>
|
||||||
|
{entry.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TodayDonutChart({
|
export function TodayDonutChart({
|
||||||
data,
|
data,
|
||||||
labelForCode,
|
labelForCode,
|
||||||
@@ -25,26 +107,18 @@ export function TodayDonutChart({
|
|||||||
variant = 'donut',
|
variant = 'donut',
|
||||||
sideLegend = false,
|
sideLegend = false,
|
||||||
}: TodayDonutChartProps) {
|
}: TodayDonutChartProps) {
|
||||||
const chartData = data.map((item) => ({
|
const { chartData, resolveColor } = useDonutChartModel({
|
||||||
...item,
|
data,
|
||||||
displayLabel: labelForCode(item.code),
|
labelForCode,
|
||||||
}));
|
colorForCode,
|
||||||
|
swatchStyleForCode,
|
||||||
|
});
|
||||||
|
|
||||||
const resolveColor = (code: string, index: number) =>
|
const innerRadius = variant === 'pie' ? 0 : '62%';
|
||||||
colorForCode?.(code, index) ??
|
const outerRadius = variant === 'pie' ? '88%' : 92;
|
||||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length];
|
|
||||||
|
|
||||||
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
const pieChart = (
|
||||||
swatchStyleForCode?.(code, index) ?? {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
backgroundColor: resolveColor(code, index),
|
|
||||||
borderColor: 'rgba(0, 0, 0, 0.18)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const innerRadius = variant === 'pie' ? 0 : 62;
|
|
||||||
const outerRadius = sideLegend ? 100 : 92;
|
|
||||||
|
|
||||||
const chart = (
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
@@ -72,54 +146,23 @@ export function TodayDonutChart({
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sideLegend) {
|
if (sideLegend) {
|
||||||
return chart;
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full items-center gap-3 overflow-hidden sm:gap-4">
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 items-center overflow-hidden">
|
||||||
|
<TodayDonutChartLegend
|
||||||
|
data={data}
|
||||||
|
labelForCode={labelForCode}
|
||||||
|
colorForCode={colorForCode}
|
||||||
|
swatchStyleForCode={swatchStyleForCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-square h-[min(100%,9.5rem)] w-[min(100%,9.5rem)] shrink-0">
|
||||||
|
{pieChart}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
return <TodayChartFrame>{pieChart}</TodayChartFrame>;
|
||||||
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">
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<span key={entry.code} className={rowClass}>
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-sm border"
|
|
||||||
style={resolveSwatchStyle(entry.code, index)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-col items-start gap-1.5">
|
|
||||||
{chartData.map((entry) => (
|
|
||||||
<span
|
|
||||||
key={entry.code}
|
|
||||||
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
|
||||||
>
|
|
||||||
{entry.displayLabel}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
|
||||||
{chartData.map((entry) => (
|
|
||||||
<span
|
|
||||||
key={entry.code}
|
|
||||||
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
|
||||||
>
|
|
||||||
{entry.count}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4 flex h-[240px] w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
|
|
||||||
{chart}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
import type { TodayChartBucket } from '@/types/today';
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
import {
|
import {
|
||||||
|
chartRankColor,
|
||||||
TODAY_CHART_AXIS_COLOR,
|
TODAY_CHART_AXIS_COLOR,
|
||||||
TODAY_CHART_COLORS,
|
|
||||||
TODAY_CHART_GRID_COLOR,
|
TODAY_CHART_GRID_COLOR,
|
||||||
TODAY_CHART_TOOLTIP_STYLE,
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
} from '@/components/today/chart-theme';
|
} from '@/components/today/chart-theme';
|
||||||
@@ -29,46 +30,48 @@ export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 36)}>
|
<TodayChartFrame>
|
||||||
<BarChart
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={chartData}
|
<BarChart
|
||||||
layout="vertical"
|
data={chartData}
|
||||||
margin={{ top: 4, right: 12, left: 4, bottom: 0 }}
|
layout="vertical"
|
||||||
>
|
margin={{ top: 4, right: 12, left: 4, bottom: 4 }}
|
||||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
>
|
||||||
<XAxis
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
||||||
type="number"
|
<XAxis
|
||||||
allowDecimals={false}
|
type="number"
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
allowDecimals={false}
|
||||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
tickLine={false}
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
/>
|
tickLine={false}
|
||||||
<YAxis
|
/>
|
||||||
type="category"
|
<YAxis
|
||||||
dataKey="shortLabel"
|
type="category"
|
||||||
width={96}
|
dataKey="shortLabel"
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
width={96}
|
||||||
axisLine={false}
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
/>
|
tickLine={false}
|
||||||
<Tooltip
|
/>
|
||||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
<Tooltip
|
||||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||||
labelFormatter={(_, payload) => {
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
labelFormatter={(_, payload) => {
|
||||||
return row?.label ?? '';
|
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||||
}}
|
return row?.label ?? '';
|
||||||
/>
|
}}
|
||||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
/>
|
||||||
{chartData.map((entry, index) => (
|
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
||||||
<Cell
|
{chartData.map((entry, index) => (
|
||||||
key={entry.code}
|
<Cell
|
||||||
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
|
key={entry.code}
|
||||||
/>
|
fill={chartRankColor(index)}
|
||||||
))}
|
/>
|
||||||
</Bar>
|
))}
|
||||||
</BarChart>
|
</Bar>
|
||||||
</ResponsiveContainer>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
130
frontend/src/components/today/TodayLabTaskActivityChart.tsx
Normal file
130
frontend/src/components/today/TodayLabTaskActivityChart.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_AXIS_COLOR,
|
||||||
|
TODAY_CHART_COMPLETED_COLOR,
|
||||||
|
TODAY_CHART_GRID_COLOR,
|
||||||
|
TODAY_CHART_RECEIVED_COLOR,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
import type { TodayStackedDayBucket } from '@/types/today';
|
||||||
|
|
||||||
|
export type LabTaskActivityChartRow = {
|
||||||
|
label: string;
|
||||||
|
completed: number;
|
||||||
|
received: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TodayLabTaskActivityChartProps {
|
||||||
|
data: LabTaskActivityChartRow[];
|
||||||
|
completedLabel: string;
|
||||||
|
receivedLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayLabTaskActivityChart({
|
||||||
|
data,
|
||||||
|
completedLabel,
|
||||||
|
receivedLabel,
|
||||||
|
}: TodayLabTaskActivityChartProps) {
|
||||||
|
return (
|
||||||
|
<TodayChartFrame>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="labTaskReceivedFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={1}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(label) => String(label)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="completed"
|
||||||
|
name={completedLabel}
|
||||||
|
stroke={TODAY_CHART_COMPLETED_COLOR}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#labTaskCompletedFill)"
|
||||||
|
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="received"
|
||||||
|
name={receivedLabel}
|
||||||
|
stroke={TODAY_CHART_RECEIVED_COLOR}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#labTaskReceivedFill)"
|
||||||
|
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{completedLabel}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{receivedLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TodayChartFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLabTaskActivityChartData(
|
||||||
|
buckets: TodayStackedDayBucket[],
|
||||||
|
): LabTaskActivityChartRow[] {
|
||||||
|
return buckets.map((bucket) => ({
|
||||||
|
label: bucket.label,
|
||||||
|
completed: bucket.completed,
|
||||||
|
received: bucket.received,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_AXIS_COLOR,
|
||||||
|
TODAY_CHART_COMPLETED_COLOR,
|
||||||
|
TODAY_CHART_GRID_COLOR,
|
||||||
|
TODAY_CHART_RECEIVED_COLOR,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
import type { TodayPartnerCasesBucket } from '@/types/today';
|
||||||
|
|
||||||
|
interface TodayPartnerCasesStackedBarChartProps {
|
||||||
|
data: TodayPartnerCasesBucket[];
|
||||||
|
completedLabel: string;
|
||||||
|
pendingLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayPartnerCasesStackedBarChart({
|
||||||
|
data,
|
||||||
|
completedLabel,
|
||||||
|
pendingLabel,
|
||||||
|
}: TodayPartnerCasesStackedBarChartProps) {
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
shortLabel: truncateLabel(item.label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TodayChartFrame>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="shortLabel"
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined;
|
||||||
|
return row?.label ?? '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="completed"
|
||||||
|
name={completedLabel}
|
||||||
|
stackId="cases"
|
||||||
|
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
maxBarSize={48}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="pending"
|
||||||
|
name={pendingLabel}
|
||||||
|
stackId="cases"
|
||||||
|
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={48}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{completedLabel}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{pendingLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TodayChartFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(label: string, max = 12): string {
|
||||||
|
if (label.length <= max) return label;
|
||||||
|
return `${label.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
'use client';
|
'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';
|
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
|
||||||
|
|
||||||
@@ -10,6 +15,15 @@ interface TodayRadialGaugeChartProps {
|
|||||||
total: number;
|
total: number;
|
||||||
percentLabel: string;
|
percentLabel: string;
|
||||||
tasksLabel: string;
|
tasksLabel: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
fillColor?: string;
|
||||||
|
showRatio?: boolean;
|
||||||
|
/** Override ring hole size (e.g. "72%" leaves more room for center labels). */
|
||||||
|
innerRadius?: string | number;
|
||||||
|
/** Override compact chart wrapper height class when size is "sm". */
|
||||||
|
compactClassName?: string;
|
||||||
|
/** Ring thickness when size is "sm". */
|
||||||
|
compactBarSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayRadialGaugeChart({
|
export function TodayRadialGaugeChart({
|
||||||
@@ -18,35 +32,60 @@ export function TodayRadialGaugeChart({
|
|||||||
total,
|
total,
|
||||||
percentLabel,
|
percentLabel,
|
||||||
tasksLabel,
|
tasksLabel,
|
||||||
|
size = 'md',
|
||||||
|
fillColor = TODAY_CHART_PRIMARY_COLOR,
|
||||||
|
showRatio = true,
|
||||||
|
innerRadius,
|
||||||
|
compactClassName,
|
||||||
|
compactBarSize,
|
||||||
}: TodayRadialGaugeChartProps) {
|
}: TodayRadialGaugeChartProps) {
|
||||||
|
const isCompact = size === 'sm';
|
||||||
const clamped = Math.max(0, Math.min(100, percent));
|
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 }];
|
||||||
|
const resolvedInnerRadius = innerRadius ?? (isCompact ? '62%' : '68%');
|
||||||
|
const resolvedBarSize = isCompact ? (compactBarSize ?? 9) : 14;
|
||||||
|
const wrapperClass = isCompact
|
||||||
|
? compactClassName ?? 'h-[108px]'
|
||||||
|
: 'h-full min-h-0 flex-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[240px] w-full">
|
<div className={`relative w-full ${wrapperClass}`}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<RadialBarChart
|
<RadialBarChart
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius="68%"
|
innerRadius={resolvedInnerRadius}
|
||||||
outerRadius="100%"
|
outerRadius="100%"
|
||||||
barSize={14}
|
barSize={resolvedBarSize}
|
||||||
data={data}
|
data={data}
|
||||||
startAngle={90}
|
startAngle={90}
|
||||||
endAngle={-270}
|
endAngle={-270}
|
||||||
>
|
>
|
||||||
|
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||||
<RadialBar
|
<RadialBar
|
||||||
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
|
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
cornerRadius={8}
|
cornerRadius={isCompact ? 6 : 8}
|
||||||
/>
|
/>
|
||||||
</RadialBarChart>
|
</RadialBarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
<div
|
||||||
<span className="text-3xl font-semibold text-text-primary">{percentLabel}</span>
|
className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center ${
|
||||||
<span className="mt-1 text-xs text-text-muted">{tasksLabel}</span>
|
innerRadius != null && isCompact ? 'px-2.5' : 'px-1'
|
||||||
{total > 0 ? (
|
}`}
|
||||||
<span className="mt-0.5 text-[11px] text-text-secondary">
|
>
|
||||||
|
<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}
|
{completed}/{total}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -11,22 +11,24 @@ export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KpiCardSkeleton() {
|
export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) {
|
||||||
return (
|
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-4 w-2/3" />
|
||||||
<SkeletonBlock className="h-8 w-16 mt-3" />
|
<SkeletonBlock className={`${tall ? 'mt-4 flex-1' : 'h-8 w-16 mt-3'}`} />
|
||||||
<SkeletonBlock className="h-3 w-1/3 mt-2" />
|
{!tall ? <SkeletonBlock className="h-3 w-1/3 mt-2" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChartCardSkeleton() {
|
export function ChartCardSkeleton() {
|
||||||
return (
|
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-4 w-1/3" />
|
||||||
<SkeletonBlock className="h-3 w-1/4 mt-2" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from 'recharts';
|
|
||||||
import type { TodayStackedDayBucket } from '@/types/today';
|
|
||||||
import {
|
|
||||||
TODAY_CHART_AXIS_COLOR,
|
|
||||||
TODAY_CHART_COMPLETED_COLOR,
|
|
||||||
TODAY_CHART_GRID_COLOR,
|
|
||||||
TODAY_CHART_RECEIVED_COLOR,
|
|
||||||
TODAY_CHART_TOOLTIP_STYLE,
|
|
||||||
} from '@/components/today/chart-theme';
|
|
||||||
|
|
||||||
interface TodayStackedBarChartProps {
|
|
||||||
data: TodayStackedDayBucket[];
|
|
||||||
completedLabel: string;
|
|
||||||
receivedLabel: string;
|
|
||||||
formatDayLabel: (code: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TodayStackedBarChart({
|
|
||||||
data,
|
|
||||||
completedLabel,
|
|
||||||
receivedLabel,
|
|
||||||
formatDayLabel,
|
|
||||||
}: TodayStackedBarChartProps) {
|
|
||||||
const chartData = data.map((item) => ({
|
|
||||||
...item,
|
|
||||||
dayLabel: formatDayLabel(item.code),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
|
||||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="dayLabel"
|
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
|
||||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
allowDecimals={false}
|
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={32}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
|
||||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
|
||||||
labelFormatter={(_, payload) => {
|
|
||||||
const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined;
|
|
||||||
return row ? formatDayLabel(row.code) : '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR }}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="completed"
|
|
||||||
name={completedLabel}
|
|
||||||
stackId="activity"
|
|
||||||
fill={TODAY_CHART_COMPLETED_COLOR}
|
|
||||||
radius={[0, 0, 0, 0]}
|
|
||||||
maxBarSize={48}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="received"
|
|
||||||
name={receivedLabel}
|
|
||||||
stackId="activity"
|
|
||||||
fill={TODAY_CHART_RECEIVED_COLOR}
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
maxBarSize={48}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
|||||||
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
||||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { canViewTreatment } from '@/components/shared/permissions';
|
import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
||||||
@@ -22,8 +22,6 @@ interface TodayUpcomingAppointmentsProps {
|
|||||||
isInitialLoad?: boolean;
|
isInitialLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE = 3;
|
|
||||||
|
|
||||||
export function TodayUpcomingAppointments({
|
export function TodayUpcomingAppointments({
|
||||||
actions,
|
actions,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -43,16 +41,16 @@ export function TodayUpcomingAppointments({
|
|||||||
if (
|
if (
|
||||||
!currentOrganization ||
|
!currentOrganization ||
|
||||||
currentOrganization.type !== 'CLINIC' ||
|
currentOrganization.type !== 'CLINIC' ||
|
||||||
!canViewTreatment(currentOrganization)
|
!canViewMyAppointmentsWeekChart(currentOrganization)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE);
|
const appointments = actions.upcomingAppointmentsToday ?? [];
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
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="mb-2 space-y-1.5">
|
||||||
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
<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 className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||||
@@ -67,7 +65,7 @@ export function TodayUpcomingAppointments({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-card-foreground">
|
<h2 className="text-sm font-semibold text-card-foreground">
|
||||||
@@ -88,44 +86,48 @@ export function TodayUpcomingAppointments({
|
|||||||
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/40">
|
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||||
{appointments.map((appointment) => {
|
<ul className="divide-y divide-border/40">
|
||||||
const start = new Date(appointment.startAt);
|
{appointments.map((appointment) => {
|
||||||
const end = new Date(appointment.endAt);
|
const start = new Date(appointment.startAt);
|
||||||
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
const end = new Date(appointment.endAt);
|
||||||
const purposeIndex = treatmentCatalog.findIndex((entry) => entry.code === appointment.purpose);
|
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
||||||
const purposeTextColor = treatmentTypeColor(
|
const purposeIndex = treatmentCatalog.findIndex(
|
||||||
appointment.purpose,
|
(entry) => entry.code === appointment.purpose,
|
||||||
purposeIndex < 0 ? 0 : purposeIndex,
|
);
|
||||||
);
|
const purposeTextColor = treatmentTypeColor(
|
||||||
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
|
appointment.purpose,
|
||||||
|
purposeIndex < 0 ? 0 : purposeIndex,
|
||||||
|
);
|
||||||
|
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={appointment.id}>
|
<li key={appointment.id}>
|
||||||
<Link
|
<Link
|
||||||
href={treatmentAppointmentHref(appointment.id)}
|
href={treatmentAppointmentHref(appointment.id)}
|
||||||
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
|
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs font-medium text-text-primary">
|
<p className="truncate text-xs font-medium text-text-primary">
|
||||||
{appointment.patientName}
|
{appointment.patientName}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate text-[11px] text-text-muted">
|
<p className="mt-0.5 truncate text-[11px] text-text-muted">
|
||||||
{timeLabel}
|
{timeLabel}
|
||||||
{appointment.purpose ? (
|
{appointment.purpose ? (
|
||||||
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
|
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,44 @@ import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-c
|
|||||||
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
|
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
|
||||||
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
|
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank-based charts (efficiency report, appointments by provider): same hex pool as
|
||||||
|
* CATALOG_PALETTE_COLORS, reordered so consecutive ranks are visually distinct.
|
||||||
|
*/
|
||||||
|
const CHART_RANK_COLOR_ORDER = [
|
||||||
|
'#fed7aa', // peach
|
||||||
|
'#93c5fd', // blue
|
||||||
|
'#86efac', // green
|
||||||
|
'#c4b5fd', // purple
|
||||||
|
'#f9a8d4', // pink
|
||||||
|
'#bae6fd', // sky
|
||||||
|
'#fde68a', // yellow
|
||||||
|
'#99f6e4', // teal
|
||||||
|
'#fca5a5', // salmon
|
||||||
|
'#ddd6fe', // lavender
|
||||||
|
'#fdba74', // orange — separated from peach
|
||||||
|
'#a5b4fc', // indigo
|
||||||
|
'#cbd5e1', // slate
|
||||||
|
'#d9f99d', // lime
|
||||||
|
'#fecaca', // light coral
|
||||||
|
'#fbcfe8', // pale pink
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const chartRankColorSet = new Set<string>(CHART_RANK_COLOR_ORDER);
|
||||||
|
|
||||||
|
export const TODAY_CHART_RANK_COLORS: readonly string[] = [
|
||||||
|
...CHART_RANK_COLOR_ORDER,
|
||||||
|
...CATALOG_PALETTE_COLORS.filter((color) => !chartRankColorSet.has(color)),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function chartRankColor(index: number): string {
|
||||||
|
return TODAY_CHART_RANK_COLORS[index % TODAY_CHART_RANK_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
/** Primary accent for single-series charts (area, gauge). */
|
/** Primary accent for single-series charts (area, gauge). */
|
||||||
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
|
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
|
||||||
|
|
||||||
/** Stacked bar segments for lab task activity. */
|
/** Lab task activity series (completed / received). */
|
||||||
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
|
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
|
||||||
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
|
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
|
||||||
|
|
||||||
|
|||||||
144
frontend/src/components/today/today-dashboard-layout.ts
Normal file
144
frontend/src/components/today/today-dashboard-layout.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { getTodayGadgetFeatureOrder } from '@/components/today/today-gadget-order';
|
||||||
|
|
||||||
|
/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */
|
||||||
|
export type TodayDashboardWidth = 1 | 2;
|
||||||
|
export type TodayDashboardHeight = 1 | 2 | 3;
|
||||||
|
|
||||||
|
export interface TodayDashboardLayout {
|
||||||
|
width: TodayDashboardWidth;
|
||||||
|
height: TodayDashboardHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared layout presets — assign when registering a dashboard widget. */
|
||||||
|
export const TODAY_DASHBOARD_LAYOUT = {
|
||||||
|
kpi: { width: 1, height: 1 },
|
||||||
|
subscription: { width: 1, height: 2 },
|
||||||
|
upcoming: { width: 2, height: 3 },
|
||||||
|
/** Week area charts (appointments, lab task activity). */
|
||||||
|
chartArea: { width: 2, height: 2 },
|
||||||
|
/** Vertical / horizontal bar charts. */
|
||||||
|
chartBar: { width: 2, height: 3 },
|
||||||
|
/** @deprecated Prefer chartArea (height 2) or chartBar (height 3). */
|
||||||
|
chart: { width: 2, height: 3 },
|
||||||
|
/** @deprecated Use chartArea or chartBar */
|
||||||
|
chartMedium: { width: 2, height: 2 },
|
||||||
|
} as const satisfies Record<string, TodayDashboardLayout>;
|
||||||
|
|
||||||
|
export interface TodayDashboardCell {
|
||||||
|
id: string;
|
||||||
|
layout: TodayDashboardLayout;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackedDashboardCell extends TodayDashboardCell {
|
||||||
|
gridColumn: string;
|
||||||
|
gridRow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareDashboardLayout(
|
||||||
|
a: TodayDashboardLayout,
|
||||||
|
b: TodayDashboardLayout,
|
||||||
|
): number {
|
||||||
|
if (a.width !== b.width) return a.width - b.width;
|
||||||
|
return a.height - b.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortDashboardCells<T extends { layout: TodayDashboardLayout; id: string }>(
|
||||||
|
cells: T[],
|
||||||
|
): T[] {
|
||||||
|
return [...cells].sort((a, b) => {
|
||||||
|
const byLayout = compareDashboardLayout(a.layout, b.layout);
|
||||||
|
if (byLayout !== 0) return byLayout;
|
||||||
|
|
||||||
|
const byFeature = getTodayGadgetFeatureOrder(a.id) - getTodayGadgetFeatureOrder(b.id);
|
||||||
|
if (byFeature !== 0) return byFeature;
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */
|
||||||
|
export function allowedStartColumns(
|
||||||
|
width: number,
|
||||||
|
columns: number,
|
||||||
|
): number[] {
|
||||||
|
if (width <= 1) {
|
||||||
|
return Array.from({ length: columns }, (_, index) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width === 2 && columns === 4) {
|
||||||
|
return [0, 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: columns - width + 1 }, (_, index) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-fit placement in ascending layout order (top-left scan).
|
||||||
|
* Multi-column widgets may only start at aligned column pairs (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';
|
||||||
78
frontend/src/components/today/today-gadget-order.ts
Normal file
78
frontend/src/components/today/today-gadget-order.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { TodayWidgetKey } from '@/types/today';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature domains for Today dashboard gadgets, ordered like app permissions:
|
||||||
|
* owner-only → staff → organizations → patients → appointments → treatment → cases → tasks
|
||||||
|
*/
|
||||||
|
export type TodayGadgetFeature =
|
||||||
|
| 'owner'
|
||||||
|
| 'staff'
|
||||||
|
| 'organizations'
|
||||||
|
| 'patients'
|
||||||
|
| 'appointments'
|
||||||
|
| 'treatment'
|
||||||
|
| 'cases'
|
||||||
|
| 'tasks';
|
||||||
|
|
||||||
|
export const TODAY_GADGET_FEATURE_SORT_ORDER: Record<TodayGadgetFeature, number> = {
|
||||||
|
owner: 0,
|
||||||
|
staff: 10,
|
||||||
|
organizations: 20,
|
||||||
|
patients: 30,
|
||||||
|
appointments: 40,
|
||||||
|
treatment: 50,
|
||||||
|
cases: 60,
|
||||||
|
tasks: 70,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** KPI widgets — keyed by TodayWidgetKey. */
|
||||||
|
export const TODAY_KPI_GADGET_FEATURE: Record<TodayWidgetKey, TodayGadgetFeature> = {
|
||||||
|
appointmentsToday: 'appointments',
|
||||||
|
patientsToday: 'patients',
|
||||||
|
treatmentsToday: 'treatment',
|
||||||
|
labCasesPendingSend: 'treatment',
|
||||||
|
providersWithoutWorkingHours: 'staff',
|
||||||
|
casesReceivedToday: 'cases',
|
||||||
|
casesInProgress: 'cases',
|
||||||
|
tasksInProgress: 'tasks',
|
||||||
|
importantTasks: 'tasks',
|
||||||
|
pendingConnections: 'organizations',
|
||||||
|
pendingStaffInvites: 'staff',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Charts and composite gadgets — keyed by stable cell id. */
|
||||||
|
export const TODAY_GADGET_ID_FEATURE: Record<string, TodayGadgetFeature> = {
|
||||||
|
subscription: 'owner',
|
||||||
|
'case-completion': 'cases',
|
||||||
|
'treatment-plan-completion': 'treatment',
|
||||||
|
'upcoming-appointments': 'treatment',
|
||||||
|
'chart-efficiency-report': 'owner',
|
||||||
|
'chart-appointments-week-all': 'appointments',
|
||||||
|
'chart-appointments-week-mine': 'treatment',
|
||||||
|
'chart-appointments-by-provider': 'appointments',
|
||||||
|
'chart-treatment-mix': 'treatment',
|
||||||
|
'chart-lab-task-activity': 'cases',
|
||||||
|
'chart-tasks-by-prosthesis': 'tasks',
|
||||||
|
'chart-case-partners-month': 'treatment',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function todayGadgetFeatureSortRank(feature: TodayGadgetFeature): number {
|
||||||
|
return TODAY_GADGET_FEATURE_SORT_ORDER[feature];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTodayGadgetFeatureOrder(gadgetId: string): number {
|
||||||
|
const direct = TODAY_GADGET_ID_FEATURE[gadgetId];
|
||||||
|
if (direct) {
|
||||||
|
return todayGadgetFeatureSortRank(direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gadgetId.startsWith('kpi-')) {
|
||||||
|
const key = gadgetId.slice(4) as TodayWidgetKey;
|
||||||
|
const feature = TODAY_KPI_GADGET_FEATURE[key];
|
||||||
|
if (feature) {
|
||||||
|
return todayGadgetFeatureSortRank(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
|
||||||
canEditStaff,
|
canEditStaff,
|
||||||
|
canViewAppointmentsTab,
|
||||||
canViewCases,
|
canViewCases,
|
||||||
canViewStaff,
|
canViewStaff,
|
||||||
canViewTasks,
|
canViewTasks,
|
||||||
@@ -67,7 +67,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
color: 'blue',
|
color: 'blue',
|
||||||
orgTypes: ['CLINIC'],
|
orgTypes: ['CLINIC'],
|
||||||
href: '/appointments',
|
href: '/appointments',
|
||||||
isVisible: (org) => canAccessAppointmentsSection(org),
|
isVisible: (org) => canViewAppointmentsTab(org),
|
||||||
formatValue: (widgets) => {
|
formatValue: (widgets) => {
|
||||||
const count = countWidget(widgets, 'appointmentsToday');
|
const count = countWidget(widgets, 'appointmentsToday');
|
||||||
return count === null ? null : String(count);
|
return count === null ? null : String(count);
|
||||||
@@ -80,7 +80,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
color: 'green',
|
color: 'green',
|
||||||
orgTypes: ['CLINIC'],
|
orgTypes: ['CLINIC'],
|
||||||
href: '/patients',
|
href: '/patients',
|
||||||
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
|
isVisible: (org) => canViewPatients(org) || canViewAppointmentsTab(org),
|
||||||
formatValue: (widgets) => {
|
formatValue: (widgets) => {
|
||||||
const count = countWidget(widgets, 'patientsToday');
|
const count = countWidget(widgets, 'patientsToday');
|
||||||
return count === null ? null : String(count);
|
return count === null ? null : String(count);
|
||||||
@@ -99,19 +99,6 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
return count === null ? null : String(count);
|
return count === null ? null : String(count);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'draftTreatments',
|
|
||||||
titleKey: 'widgetDraftTreatments',
|
|
||||||
icon: ClipboardList,
|
|
||||||
color: 'yellow',
|
|
||||||
orgTypes: ['CLINIC'],
|
|
||||||
href: '/treatment',
|
|
||||||
isVisible: (org) => canViewTreatment(org),
|
|
||||||
formatValue: (widgets) => {
|
|
||||||
const count = countWidget(widgets, 'draftTreatments');
|
|
||||||
return count === null ? null : String(count);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'labCasesPendingSend',
|
key: 'labCasesPendingSend',
|
||||||
titleKey: 'widgetLabCasesPendingSend',
|
titleKey: 'widgetLabCasesPendingSend',
|
||||||
@@ -203,26 +190,6 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
return count === null ? null : String(count);
|
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',
|
key: 'pendingStaffInvites',
|
||||||
titleKey: 'widgetPendingStaffInvites',
|
titleKey: 'widgetPendingStaffInvites',
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export function AppointmentScheduleGrid({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canBook) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onAppointmentClick?.(apt);
|
onAppointmentClick?.(apt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +376,10 @@ export function AppointmentScheduleGrid({
|
|||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
anchorRect={overlapPopover.anchorRect}
|
anchorRect={overlapPopover.anchorRect}
|
||||||
onSelect={(apt) => {
|
onSelect={(apt) => {
|
||||||
|
if (!canBook) {
|
||||||
|
setOverlapPopover(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
||||||
if (
|
if (
|
||||||
provider &&
|
provider &&
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { OrgTypeName } from '@/components/shared/permissions';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
canViewAppointmentsTab,
|
||||||
canViewCases,
|
canViewCases,
|
||||||
canViewTasks,
|
canViewTasks,
|
||||||
canViewTab,
|
canViewTab,
|
||||||
@@ -73,7 +73,7 @@ function Sidebar() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (item.path === '/appointments') {
|
if (item.path === '/appointments') {
|
||||||
return canAccessAppointmentsSection(currentOrganization);
|
return canViewAppointmentsTab(currentOrganization);
|
||||||
}
|
}
|
||||||
if (item.path === '/cases') {
|
if (item.path === '/cases') {
|
||||||
return canViewCases(currentOrganization);
|
return canViewCases(currentOrganization);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
|
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
|
||||||
* Treatment types own the canonical hex values; prosthesis types reuse the same codes.
|
* Treatment and prosthesis each have dedicated hex maps — prosthesis colors are unique
|
||||||
|
* within the prosthesis catalog (no duplicate swatches on charts or badges).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -19,28 +20,28 @@ export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
|||||||
continue_treatment: '#99f6e4',
|
continue_treatment: '#99f6e4',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */
|
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
|
||||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||||
pfm_crown: '#cbd5e1',
|
pfm_crown: '#e2e8f0',
|
||||||
pfz_crown: '#86efac',
|
pfz_crown: '#bbf7d0',
|
||||||
monolithic_zirconia: '#99f6e4',
|
monolithic_zirconia: '#e0f2fe',
|
||||||
glass_ceramic_crown: '#fde68a',
|
glass_ceramic_crown: '#fef08a',
|
||||||
full_metal_crown: '#cbd5e1',
|
full_metal_crown: '#d4d4d8',
|
||||||
temporary_resin_crown: '#bae6fd',
|
temporary_resin_crown: '#bae6fd',
|
||||||
pmma: '#93c5fd',
|
pmma: '#7dd3fc',
|
||||||
peek_crown: '#99f6e4',
|
peek_crown: '#5eead4',
|
||||||
veneer_zirconia: '#86efac',
|
veneer_zirconia: '#6ee7b7',
|
||||||
veneer_ips_press: '#fed7aa',
|
veneer_ips_press: '#fed7aa',
|
||||||
veneer_ips_cad: '#fdba74',
|
veneer_ips_cad: '#fdba74',
|
||||||
soft_structure: '#ddd6fe',
|
soft_structure: '#ddd6fe',
|
||||||
customized_abutment: '#a5b4fc',
|
customized_abutment: '#a5b4fc',
|
||||||
prefabricated_abutment: '#93c5fd',
|
prefabricated_abutment: '#c7d2fe',
|
||||||
ti_base_abutment: '#bae6fd',
|
ti_base_abutment: '#bfdbfe',
|
||||||
multi_unit_abutment: '#a5b4fc',
|
multi_unit_abutment: '#818cf8',
|
||||||
zirconia_abutment: '#86efac',
|
zirconia_abutment: '#34d399',
|
||||||
screw_retained: '#c4b5fd',
|
screw_retained: '#e9d5ff',
|
||||||
zirconia_overlay: '#99f6e4',
|
zirconia_overlay: '#2dd4bf',
|
||||||
ips_overlay: '#fde68a',
|
ips_overlay: '#fef3c7',
|
||||||
smile_design: '#f9a8d4',
|
smile_design: '#f9a8d4',
|
||||||
mockup: '#fbcfe8',
|
mockup: '#fbcfe8',
|
||||||
};
|
};
|
||||||
@@ -54,7 +55,12 @@ export const CATALOG_FALLBACK_COLORS = [
|
|||||||
'#fbcfe8',
|
'#fbcfe8',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Ordered palette for charts and rotating unknown catalog codes. */
|
/** Fallback rotation for unknown prosthesis codes — drawn from the prosthesis palette. */
|
||||||
|
export const PROSTHESIS_FALLBACK_COLORS: readonly string[] = [
|
||||||
|
...new Set(Object.values(PROSTHESIS_TYPE_COLORS)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ordered palette for charts and rotating unknown treatment catalog codes. */
|
||||||
export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
||||||
'#fed7aa',
|
'#fed7aa',
|
||||||
'#fdba74',
|
'#fdba74',
|
||||||
@@ -72,12 +78,25 @@ export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
|||||||
'#ddd6fe',
|
'#ddd6fe',
|
||||||
'#d9f99d',
|
'#d9f99d',
|
||||||
'#fbcfe8',
|
'#fbcfe8',
|
||||||
|
...PROSTHESIS_FALLBACK_COLORS.filter(
|
||||||
|
(color) =>
|
||||||
|
![
|
||||||
|
'#fed7aa',
|
||||||
|
'#fdba74',
|
||||||
|
'#bae6fd',
|
||||||
|
'#f9a8d4',
|
||||||
|
'#ddd6fe',
|
||||||
|
'#fbcfe8',
|
||||||
|
'#a5b4fc',
|
||||||
|
].includes(color),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function resolveCatalogTypeColor(
|
export function resolveCatalogTypeColor(
|
||||||
code: string,
|
code: string,
|
||||||
colorMap: Record<string, string>,
|
colorMap: Record<string, string>,
|
||||||
index = 0,
|
index = 0,
|
||||||
|
fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS,
|
||||||
): string {
|
): string {
|
||||||
return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length];
|
return colorMap[code] ?? fallbackColors[index % fallbackColors.length];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import {
|
import {
|
||||||
|
PROSTHESIS_FALLBACK_COLORS,
|
||||||
PROSTHESIS_TYPE_COLORS,
|
PROSTHESIS_TYPE_COLORS,
|
||||||
resolveCatalogTypeColor,
|
resolveCatalogTypeColor,
|
||||||
} from '@/components/ui/treatment/catalog-type-colors';
|
} from '@/components/ui/treatment/catalog-type-colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
||||||
* headers / badges). Uses the same hex palette as treatment types.
|
* headers / badges). Uses a dedicated pastel map (unique per prosthesis code).
|
||||||
*
|
*
|
||||||
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
const BADGE_INK = '#14253d';
|
const BADGE_INK = '#14253d';
|
||||||
|
|
||||||
export function prosthesisTypeColor(code: string, index = 0): string {
|
export function prosthesisTypeColor(code: string, index = 0): string {
|
||||||
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index);
|
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filled swatch (small indicator dots). */
|
/** Filled swatch (small indicator dots). */
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 8px;
|
||||||
|
--today-grid-unit: 5.75rem;
|
||||||
--color-background-primary: #000c1c;
|
--color-background-primary: #000c1c;
|
||||||
--color-background-secondary: #0a1520;
|
--color-background-secondary: #0a1520;
|
||||||
--color-background-card: #14253d;
|
--color-background-card: #14253d;
|
||||||
@@ -272,6 +272,13 @@ select option {
|
|||||||
border-radius: var(--radius-lg);
|
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[data-theme='dark'] .surface-card,
|
||||||
:root:not([data-theme='light']) .surface-card {
|
:root:not([data-theme='light']) .surface-card {
|
||||||
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
||||||
|
|||||||
@@ -23,36 +23,59 @@ export type TodayStackedDayBucket = {
|
|||||||
received: number;
|
received: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TodayPartnerCasesBucket = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TodayCompletionGauge = {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TodaySummaryCharts = {
|
export type TodaySummaryCharts = {
|
||||||
treatmentMixWeek?: TodayChartBucket[];
|
treatmentMixWeek?: TodayChartBucket[];
|
||||||
tasksByWorkflowStep?: TodayChartBucket[];
|
tasksByProsthesis?: TodayChartBucket[];
|
||||||
appointmentsByProvider?: TodayChartBucket[];
|
appointmentsByProvider?: TodayChartBucket[];
|
||||||
caseCompletion?: {
|
caseCompletion?: TodayCompletionGauge;
|
||||||
completed: number;
|
treatmentPlanCompletion?: TodayCompletionGauge;
|
||||||
total: number;
|
|
||||||
percent: number;
|
|
||||||
};
|
|
||||||
appointmentsWeekAll?: TodayChartBucket[];
|
appointmentsWeekAll?: TodayChartBucket[];
|
||||||
appointmentsWeekMine?: TodayChartBucket[];
|
appointmentsWeekMine?: TodayChartBucket[];
|
||||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||||
inProgressTasksByProsthesis?: TodayChartBucket[];
|
casePartnersMonth?: TodayPartnerCasesBucket[];
|
||||||
|
efficiencyReport?: TodayChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TodayWidgetKey =
|
export type TodayWidgetKey =
|
||||||
| 'appointmentsToday'
|
| 'appointmentsToday'
|
||||||
| 'patientsToday'
|
| 'patientsToday'
|
||||||
| 'treatmentsToday'
|
| 'treatmentsToday'
|
||||||
| 'draftTreatments'
|
|
||||||
| 'labCasesPendingSend'
|
| 'labCasesPendingSend'
|
||||||
| 'casesReceivedToday'
|
| 'casesReceivedToday'
|
||||||
| 'casesInProgress'
|
| 'casesInProgress'
|
||||||
| 'tasksInProgress'
|
| 'tasksInProgress'
|
||||||
| 'importantTasks'
|
| 'importantTasks'
|
||||||
| 'pendingConnections'
|
| 'pendingConnections'
|
||||||
| 'seats'
|
|
||||||
| 'pendingStaffInvites'
|
| 'pendingStaffInvites'
|
||||||
| 'providersWithoutWorkingHours';
|
| '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<
|
export type TodaySummaryWidgets = Partial<
|
||||||
Record<
|
Record<
|
||||||
TodayWidgetKey,
|
TodayWidgetKey,
|
||||||
@@ -68,6 +91,7 @@ export interface TodaySummaryData {
|
|||||||
widgets: TodaySummaryWidgets;
|
widgets: TodaySummaryWidgets;
|
||||||
charts: TodaySummaryCharts;
|
charts: TodaySummaryCharts;
|
||||||
actions: TodaySummaryActions;
|
actions: TodaySummaryActions;
|
||||||
|
subscription?: TodaySubscriptionSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodaySummaryResponse {
|
export interface TodaySummaryResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user