Case completion and tasks completion gadgets added.
This commit is contained in:
@@ -30,20 +30,29 @@ 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[];
|
||||||
tasksByProsthesis?: 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[];
|
||||||
|
casePartnersMonth?: PartnerCasesBucket[];
|
||||||
efficiencyReport?: ChartBucket[];
|
efficiencyReport?: ChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +147,27 @@ 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),
|
||||||
@@ -198,6 +228,19 @@ 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));
|
||||||
@@ -515,10 +558,10 @@ export class TodayService {
|
|||||||
widgets.pendingConnections = { count };
|
widgets.pendingConnections = { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getActiveEditAccessUserIds(
|
private async getActiveEditAccessMembers(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
|
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
|
||||||
): Promise<string[]> {
|
): Promise<Array<{ userId: string; isOwner: boolean }>> {
|
||||||
const members = await this.prisma.membership.findMany({
|
const members = await this.prisma.membership.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -533,10 +576,42 @@ export class TodayService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: { userId: true },
|
select: { userId: true, isOwner: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return members.map((member) => member.userId);
|
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(
|
private async loadClinicEfficiencyReport(
|
||||||
@@ -544,13 +619,10 @@ export class TodayService {
|
|||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
charts: TodayCharts,
|
charts: TodayCharts,
|
||||||
) {
|
) {
|
||||||
const eligibleUserIds = await this.getActiveEditAccessUserIds(
|
const members = await this.getActiveEditAccessMembers(
|
||||||
organizationId,
|
organizationId,
|
||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
);
|
);
|
||||||
if (eligibleUserIds.length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
const grouped = await this.prisma.treatment.groupBy({
|
const grouped = await this.prisma.treatment.groupBy({
|
||||||
@@ -558,31 +630,22 @@ export class TodayService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
treatmentAt: { gte: monthStart, lt: rangeEnd },
|
treatmentAt: { gte: monthStart, lt: rangeEnd },
|
||||||
providerUserId: { in: eligibleUserIds },
|
providerUserId: { in: members.map((member) => member.userId) },
|
||||||
},
|
},
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const countsByUser = new Map(
|
const countsByUser = new Map(
|
||||||
eligibleUserIds.map((userId) => [userId, 0]),
|
members.map((member) => [member.userId, 0]),
|
||||||
);
|
);
|
||||||
for (const row of grouped) {
|
for (const row of grouped) {
|
||||||
countsByUser.set(row.providerUserId, aggregateCount(row._count));
|
countsByUser.set(row.providerUserId, aggregateCount(row._count));
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await this.prisma.user.findMany({
|
const report = await this.buildEfficiencyReportRows(members, countsByUser);
|
||||||
where: { id: { in: eligibleUserIds } },
|
if (report) {
|
||||||
select: { id: true, name: true },
|
charts.efficiencyReport = report;
|
||||||
});
|
}
|
||||||
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
|
||||||
|
|
||||||
charts.efficiencyReport = eligibleUserIds
|
|
||||||
.map((userId) => ({
|
|
||||||
code: userId,
|
|
||||||
label: nameById.get(userId) ?? userId,
|
|
||||||
count: countsByUser.get(userId) ?? 0,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadLabEfficiencyReport(
|
private async loadLabEfficiencyReport(
|
||||||
@@ -590,13 +653,10 @@ export class TodayService {
|
|||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
charts: TodayCharts,
|
charts: TodayCharts,
|
||||||
) {
|
) {
|
||||||
const eligibleUserIds = await this.getActiveEditAccessUserIds(
|
const members = await this.getActiveEditAccessMembers(
|
||||||
labOrganizationId,
|
labOrganizationId,
|
||||||
'TAB_TASKS_EDIT',
|
'TAB_TASKS_EDIT',
|
||||||
);
|
);
|
||||||
if (eligibleUserIds.length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
|
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
|
||||||
@@ -604,7 +664,7 @@ export class TodayService {
|
|||||||
where: {
|
where: {
|
||||||
toStatus: LabTaskStatus.COMPLETED,
|
toStatus: LabTaskStatus.COMPLETED,
|
||||||
changedAt: { gte: monthStart, lt: rangeEnd },
|
changedAt: { gte: monthStart, lt: rangeEnd },
|
||||||
changedByUserId: { in: eligibleUserIds },
|
changedByUserId: { in: members.map((member) => member.userId) },
|
||||||
task: {
|
task: {
|
||||||
labCase: {
|
labCase: {
|
||||||
sends: { some: { organizationId: labOrganizationId } },
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
@@ -615,26 +675,17 @@ export class TodayService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const countsByUser = new Map(
|
const countsByUser = new Map(
|
||||||
eligibleUserIds.map((userId) => [userId, 0]),
|
members.map((member) => [member.userId, 0]),
|
||||||
);
|
);
|
||||||
for (const row of grouped) {
|
for (const row of grouped) {
|
||||||
if (!row.changedByUserId) continue;
|
if (!row.changedByUserId) continue;
|
||||||
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
|
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await this.prisma.user.findMany({
|
const report = await this.buildEfficiencyReportRows(members, countsByUser);
|
||||||
where: { id: { in: eligibleUserIds } },
|
if (report) {
|
||||||
select: { id: true, name: true },
|
charts.efficiencyReport = report;
|
||||||
});
|
}
|
||||||
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
|
||||||
|
|
||||||
charts.efficiencyReport = eligibleUserIds
|
|
||||||
.map((userId) => ({
|
|
||||||
code: userId,
|
|
||||||
label: nameById.get(userId) ?? userId,
|
|
||||||
count: countsByUser.get(userId) ?? 0,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildSubscriptionWidget(
|
private async buildSubscriptionWidget(
|
||||||
@@ -845,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(
|
||||||
@@ -986,6 +1066,86 @@ export class TodayService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadCasePartnersMonth(
|
||||||
|
orgType: 'CLINIC' | 'LAB',
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
scopeToUser: boolean,
|
||||||
|
rangeEnd: Date,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||||
|
|
||||||
|
const cases = await this.prisma.labCase.findMany({
|
||||||
|
where: {
|
||||||
|
sentAt: { gte: rangeStart, lt: rangeEnd },
|
||||||
|
...(orgType === 'CLINIC'
|
||||||
|
? {
|
||||||
|
destinationOrganizationId: { not: null },
|
||||||
|
treatment: {
|
||||||
|
organizationId,
|
||||||
|
...(scopeToUser ? { providerUserId: userId } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
sends: { some: { organizationId } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
destinationOrganizationId: true,
|
||||||
|
tasks: { select: { status: true } },
|
||||||
|
treatment: { select: { organizationId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsByPartner = new Map<string, { completed: number; total: number }>();
|
||||||
|
|
||||||
|
for (const labCase of cases) {
|
||||||
|
const partnerId =
|
||||||
|
orgType === 'CLINIC'
|
||||||
|
? labCase.destinationOrganizationId
|
||||||
|
: labCase.treatment.organizationId;
|
||||||
|
if (!partnerId) continue;
|
||||||
|
|
||||||
|
const isCompleted =
|
||||||
|
labCase.tasks.length > 0 &&
|
||||||
|
labCase.tasks.every((task) => task.status === LabTaskStatus.COMPLETED);
|
||||||
|
|
||||||
|
const entry = countsByPartner.get(partnerId) ?? { completed: 0, total: 0 };
|
||||||
|
entry.total += 1;
|
||||||
|
if (isCompleted) entry.completed += 1;
|
||||||
|
countsByPartner.set(partnerId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countsByPartner.size === 0) {
|
||||||
|
charts.casePartnersMonth = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...countsByPartner.entries()]
|
||||||
|
.map(([code, counts]) => ({
|
||||||
|
code,
|
||||||
|
completed: counts.completed,
|
||||||
|
pending: counts.total - counts.completed,
|
||||||
|
total: counts.total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const partners = await this.prisma.organization.findMany({
|
||||||
|
where: { id: { in: sorted.map((row) => row.code) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(partners.map((org) => [org.id, org.name]));
|
||||||
|
|
||||||
|
charts.casePartnersMonth = sorted.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
label: nameById.get(row.code) ?? row.code,
|
||||||
|
completed: row.completed,
|
||||||
|
pending: row.pending,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
||||||
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
@@ -1086,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');
|
||||||
|
|||||||
@@ -235,8 +235,16 @@
|
|||||||
"chartCaseCompletionSubtitle": "All active cases",
|
"chartCaseCompletionSubtitle": "All active cases",
|
||||||
"chartCaseCompletionPercent": "{percent}%",
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
"chartCaseCompletionTasks": "Tasks completed",
|
"chartCaseCompletionTasks": "Tasks completed",
|
||||||
|
"chartTreatmentPlanCompletionTitle": "Treatment Plan Completion",
|
||||||
|
"chartTreatmentPlanCompletionSubtitle": "All appointments",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
||||||
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
||||||
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
||||||
|
"chartCasePartnersClinicTitle": "Cases by Lab",
|
||||||
|
"chartCasePartnersLabTitle": "Cases by Clinic",
|
||||||
|
"chartCasePartnersSubtitle": "Last 30 days",
|
||||||
|
"chartCasePartnersSentLegend": "Sent",
|
||||||
|
"chartCasePartnersOpenLegend": "In progress",
|
||||||
"chartEfficiencyReportTitle": "Efficiency Report",
|
"chartEfficiencyReportTitle": "Efficiency Report",
|
||||||
"chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days",
|
"chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days",
|
||||||
"chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days",
|
"chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days",
|
||||||
|
|||||||
@@ -235,8 +235,16 @@
|
|||||||
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
||||||
"chartCaseCompletionPercent": "{percent}٪",
|
"chartCaseCompletionPercent": "{percent}٪",
|
||||||
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||||
|
"chartTreatmentPlanCompletionTitle": "تکمیل طرح درمان",
|
||||||
|
"chartTreatmentPlanCompletionSubtitle": "همه نوبتها",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||||
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||||
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||||
|
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||||
|
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||||
|
"chartCasePartnersSubtitle": "۳۰ روز گذشته",
|
||||||
|
"chartCasePartnersSentLegend": "ارسالشده",
|
||||||
|
"chartCasePartnersOpenLegend": "در حال انجام",
|
||||||
"chartEfficiencyReportTitle": "گزارش کارایی",
|
"chartEfficiencyReportTitle": "گزارش کارایی",
|
||||||
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
||||||
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
||||||
|
|||||||
@@ -235,8 +235,16 @@
|
|||||||
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
||||||
"chartCaseCompletionPercent": "{percent}%",
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
"chartCaseCompletionTasks": "Taken voltooid",
|
"chartCaseCompletionTasks": "Taken voltooid",
|
||||||
|
"chartTreatmentPlanCompletionTitle": "Behandelplanvoltooiing",
|
||||||
|
"chartTreatmentPlanCompletionSubtitle": "Alle afspraken",
|
||||||
|
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
||||||
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
||||||
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
||||||
|
"chartCasePartnersClinicTitle": "Cases per lab",
|
||||||
|
"chartCasePartnersLabTitle": "Cases per kliniek",
|
||||||
|
"chartCasePartnersSubtitle": "Afgelopen 30 dagen",
|
||||||
|
"chartCasePartnersSentLegend": "Verzonden",
|
||||||
|
"chartCasePartnersOpenLegend": "In uitvoering",
|
||||||
"chartEfficiencyReportTitle": "Efficiëntierapport",
|
"chartEfficiencyReportTitle": "Efficiëntierapport",
|
||||||
"chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen",
|
"chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen",
|
||||||
"chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen",
|
"chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen",
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Package } from 'lucide-react';
|
|
||||||
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 { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme';
|
import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme';
|
||||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||||
|
import type { TodayCompletionGauge } from '@/types/today';
|
||||||
|
|
||||||
interface TodayCaseCompletionKpiCardProps {
|
export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge {
|
||||||
completed: number;
|
title: string;
|
||||||
total: number;
|
subtitle: string;
|
||||||
percent: number;
|
percentLabel: string;
|
||||||
|
ratioLabel: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayCaseCompletionKpiCard({
|
export function TodayCompletionGaugeKpiCard({
|
||||||
completed,
|
completed,
|
||||||
total,
|
total,
|
||||||
percent,
|
percent,
|
||||||
}: TodayCaseCompletionKpiCardProps) {
|
title,
|
||||||
const t = useTranslations('today');
|
subtitle,
|
||||||
|
percentLabel,
|
||||||
const percentLabel =
|
ratioLabel,
|
||||||
total > 0 ? t('chartCaseCompletionPercent', { percent }) : '—';
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
}: TodayCompletionGaugeKpiCardProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/cases"
|
href={href}
|
||||||
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
|
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">
|
<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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{t('chartCaseCompletionTitle')}</p>
|
<p className="text-sm font-medium">{title}</p>
|
||||||
<p className="mt-0.5 truncate text-xs text-text-muted">
|
<p className="mt-0.5 truncate text-xs text-text-muted">{subtitle}</p>
|
||||||
{t('chartCaseCompletionSubtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Package className="h-4 w-4 shrink-0 !text-current" aria-hidden />
|
<Icon className="h-4 w-4 shrink-0 !text-current" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
|
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
|
||||||
@@ -49,8 +51,8 @@ export function TodayCaseCompletionKpiCard({
|
|||||||
percent={total > 0 ? percent : 0}
|
percent={total > 0 ? percent : 0}
|
||||||
completed={completed}
|
completed={completed}
|
||||||
total={total}
|
total={total}
|
||||||
percentLabel={percentLabel}
|
percentLabel={total > 0 ? percentLabel : '—'}
|
||||||
tasksLabel={t('chartCaseCompletionTasks')}
|
tasksLabel={ratioLabel}
|
||||||
fillColor={TODAY_CHART_COMPLETED_COLOR}
|
fillColor={TODAY_CHART_COMPLETED_COLOR}
|
||||||
showRatio={total > 0}
|
showRatio={total > 0}
|
||||||
/>
|
/>
|
||||||
@@ -4,6 +4,8 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
import {
|
||||||
|
canEditCases,
|
||||||
|
canEditTreatment,
|
||||||
canViewAppointmentsTab,
|
canViewAppointmentsTab,
|
||||||
canViewCases,
|
canViewCases,
|
||||||
canViewLabCasesOrTasks,
|
canViewLabCasesOrTasks,
|
||||||
@@ -22,7 +24,9 @@ import {
|
|||||||
import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid';
|
import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid';
|
||||||
import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart';
|
import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart';
|
||||||
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||||
import { TodayCaseCompletionKpiCard } from '@/components/today/TodayCaseCompletionKpiCard';
|
import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart';
|
||||||
|
import { Package, Stethoscope, type LucideIcon } from 'lucide-react';
|
||||||
|
import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard';
|
||||||
import {
|
import {
|
||||||
mapLabTaskActivityChartData,
|
mapLabTaskActivityChartData,
|
||||||
TodayLabTaskActivityChart,
|
TodayLabTaskActivityChart,
|
||||||
@@ -42,6 +46,7 @@ import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/wi
|
|||||||
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import type {
|
import type {
|
||||||
|
TodayCompletionGauge,
|
||||||
TodaySubscriptionSnapshot,
|
TodaySubscriptionSnapshot,
|
||||||
TodaySummaryActions,
|
TodaySummaryActions,
|
||||||
TodaySummaryCharts,
|
TodaySummaryCharts,
|
||||||
@@ -78,6 +83,12 @@ export function TodayDashboard({
|
|||||||
currentOrganization &&
|
currentOrganization &&
|
||||||
canViewMyAppointmentsWeekChart(currentOrganization);
|
canViewMyAppointmentsWeekChart(currentOrganization);
|
||||||
|
|
||||||
|
const showCasePartnersChart = Boolean(
|
||||||
|
currentOrganization &&
|
||||||
|
((orgType === 'CLINIC' && canEditTreatment(currentOrganization)) ||
|
||||||
|
(orgType === 'LAB' && canEditCases(currentOrganization))),
|
||||||
|
);
|
||||||
|
|
||||||
const showCharts = useMemo(() => {
|
const showCharts = useMemo(() => {
|
||||||
if (!orgType || !currentOrganization) return false;
|
if (!orgType || !currentOrganization) return false;
|
||||||
if (orgType === 'CLINIC') {
|
if (orgType === 'CLINIC') {
|
||||||
@@ -104,12 +115,18 @@ export function TodayDashboard({
|
|||||||
Boolean(currentOrganization && canViewCases(currentOrganization)) &&
|
Boolean(currentOrganization && canViewCases(currentOrganization)) &&
|
||||||
(isInitialLoad || charts.caseCompletion !== undefined);
|
(isInitialLoad || charts.caseCompletion !== undefined);
|
||||||
|
|
||||||
|
const showTreatmentPlanCompletionCard =
|
||||||
|
orgType === 'CLINIC' &&
|
||||||
|
Boolean(currentOrganization && canEditTreatment(currentOrganization)) &&
|
||||||
|
(isInitialLoad || charts.treatmentPlanCompletion !== undefined);
|
||||||
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo(() => {
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return buildSkeletonCells({
|
return buildSkeletonCells({
|
||||||
kpiDefinitions,
|
kpiDefinitions,
|
||||||
showSubscriptionCard,
|
showSubscriptionCard,
|
||||||
showCaseCompletionCard,
|
showCaseCompletionCard,
|
||||||
|
showTreatmentPlanCompletionCard,
|
||||||
showUpcoming: Boolean(showUpcoming),
|
showUpcoming: Boolean(showUpcoming),
|
||||||
showCharts,
|
showCharts,
|
||||||
orgType,
|
orgType,
|
||||||
@@ -118,6 +135,7 @@ export function TodayDashboard({
|
|||||||
currentOrganization &&
|
currentOrganization &&
|
||||||
canViewMyAppointmentsWeekChart(currentOrganization),
|
canViewMyAppointmentsWeekChart(currentOrganization),
|
||||||
),
|
),
|
||||||
|
showCasePartnersChart,
|
||||||
charts,
|
charts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -133,6 +151,9 @@ export function TodayDashboard({
|
|||||||
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||||
showCaseCompletionCard:
|
showCaseCompletionCard:
|
||||||
showCaseCompletionCard && charts.caseCompletion !== undefined,
|
showCaseCompletionCard && charts.caseCompletion !== undefined,
|
||||||
|
showTreatmentPlanCompletionCard:
|
||||||
|
showTreatmentPlanCompletionCard &&
|
||||||
|
charts.treatmentPlanCompletion !== undefined,
|
||||||
showUpcoming: Boolean(showUpcoming),
|
showUpcoming: Boolean(showUpcoming),
|
||||||
showCharts,
|
showCharts,
|
||||||
orgType,
|
orgType,
|
||||||
@@ -144,6 +165,7 @@ export function TodayDashboard({
|
|||||||
kpiDefinitions,
|
kpiDefinitions,
|
||||||
showSubscriptionCard,
|
showSubscriptionCard,
|
||||||
showCaseCompletionCard,
|
showCaseCompletionCard,
|
||||||
|
showTreatmentPlanCompletionCard,
|
||||||
showUpcoming,
|
showUpcoming,
|
||||||
showCharts,
|
showCharts,
|
||||||
orgType,
|
orgType,
|
||||||
@@ -176,11 +198,13 @@ function buildSkeletonCells(options: {
|
|||||||
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
|
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
|
||||||
showSubscriptionCard: boolean;
|
showSubscriptionCard: boolean;
|
||||||
showCaseCompletionCard: boolean;
|
showCaseCompletionCard: boolean;
|
||||||
|
showTreatmentPlanCompletionCard: boolean;
|
||||||
showUpcoming: boolean;
|
showUpcoming: boolean;
|
||||||
showCharts: boolean;
|
showCharts: boolean;
|
||||||
orgType?: 'CLINIC' | 'LAB';
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
showMyAppointmentsWeekChart: boolean;
|
showMyAppointmentsWeekChart: boolean;
|
||||||
|
showCasePartnersChart: boolean;
|
||||||
charts: TodaySummaryCharts;
|
charts: TodaySummaryCharts;
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const cells: TodayDashboardCell[] = [];
|
const cells: TodayDashboardCell[] = [];
|
||||||
@@ -191,6 +215,7 @@ function buildSkeletonCells(options: {
|
|||||||
options.orgType,
|
options.orgType,
|
||||||
options.isOwner,
|
options.isOwner,
|
||||||
options.showMyAppointmentsWeekChart,
|
options.showMyAppointmentsWeekChart,
|
||||||
|
options.showCasePartnersChart,
|
||||||
);
|
);
|
||||||
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
|
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
|
||||||
cells.push({
|
cells.push({
|
||||||
@@ -237,6 +262,14 @@ function buildSkeletonCells(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.showTreatmentPlanCompletionCard) {
|
||||||
|
cells.push({
|
||||||
|
id: 'treatment-plan-completion-skeleton',
|
||||||
|
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||||
|
content: <KpiCardSkeleton tall />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const definition of options.kpiDefinitions) {
|
for (const definition of options.kpiDefinitions) {
|
||||||
cells.push({
|
cells.push({
|
||||||
id: `kpi-skeleton-${definition.key}`,
|
id: `kpi-skeleton-${definition.key}`,
|
||||||
@@ -258,6 +291,7 @@ function buildDashboardCells(options: {
|
|||||||
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
||||||
showSubscriptionCard: boolean;
|
showSubscriptionCard: boolean;
|
||||||
showCaseCompletionCard: boolean;
|
showCaseCompletionCard: boolean;
|
||||||
|
showTreatmentPlanCompletionCard: boolean;
|
||||||
showUpcoming: boolean;
|
showUpcoming: boolean;
|
||||||
showCharts: boolean;
|
showCharts: boolean;
|
||||||
orgType?: 'CLINIC' | 'LAB';
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
@@ -277,6 +311,12 @@ function buildDashboardCells(options: {
|
|||||||
options.currentOrganization &&
|
options.currentOrganization &&
|
||||||
canViewMyAppointmentsWeekChart(options.currentOrganization),
|
canViewMyAppointmentsWeekChart(options.currentOrganization),
|
||||||
),
|
),
|
||||||
|
showCasePartnersChart:
|
||||||
|
Boolean(options.currentOrganization) &&
|
||||||
|
((options.orgType === 'CLINIC' &&
|
||||||
|
canEditTreatment(options.currentOrganization)) ||
|
||||||
|
(options.orgType === 'LAB' &&
|
||||||
|
canEditCases(options.currentOrganization))),
|
||||||
dayLabelFormatter: options.dayLabelFormatter,
|
dayLabelFormatter: options.dayLabelFormatter,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -301,17 +341,35 @@ function buildDashboardCells(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) {
|
if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) {
|
||||||
const caseCompletion = options.charts.caseCompletion;
|
pushCompletionGaugeCell(cells, {
|
||||||
cells.push({
|
|
||||||
id: 'case-completion',
|
id: 'case-completion',
|
||||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
gauge: options.charts.caseCompletion,
|
||||||
content: (
|
title: options.t('chartCaseCompletionTitle'),
|
||||||
<TodayCaseCompletionKpiCard
|
subtitle: options.t('chartCaseCompletionSubtitle'),
|
||||||
completed={caseCompletion.completed}
|
percentLabel: options.t('chartCaseCompletionPercent', {
|
||||||
total={caseCompletion.total}
|
percent: options.charts.caseCompletion.percent,
|
||||||
percent={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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +403,7 @@ function buildChartCells(options: {
|
|||||||
orgType?: 'CLINIC' | 'LAB';
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
showMyAppointmentsWeekChart: boolean;
|
showMyAppointmentsWeekChart: boolean;
|
||||||
|
showCasePartnersChart: boolean;
|
||||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||||
@@ -528,6 +587,38 @@ function buildChartCells(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return cells;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +627,7 @@ function countVisibleCharts(
|
|||||||
orgType?: 'CLINIC' | 'LAB',
|
orgType?: 'CLINIC' | 'LAB',
|
||||||
isOwner = false,
|
isOwner = false,
|
||||||
showMyAppointmentsWeekChart = false,
|
showMyAppointmentsWeekChart = false,
|
||||||
|
showCasePartnersChart = false,
|
||||||
): number {
|
): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (orgType === 'CLINIC') {
|
if (orgType === 'CLINIC') {
|
||||||
@@ -544,10 +636,12 @@ function countVisibleCharts(
|
|||||||
showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0;
|
showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0;
|
||||||
count += charts.appointmentsByProvider !== undefined ? 1 : 0;
|
count += charts.appointmentsByProvider !== undefined ? 1 : 0;
|
||||||
count += charts.treatmentMixWeek !== undefined ? 1 : 0;
|
count += charts.treatmentMixWeek !== undefined ? 1 : 0;
|
||||||
|
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||||
}
|
}
|
||||||
if (orgType === 'LAB') {
|
if (orgType === 'LAB') {
|
||||||
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||||
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
||||||
|
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isOwner &&
|
isOwner &&
|
||||||
@@ -558,3 +652,35 @@ function countVisibleCharts(
|
|||||||
}
|
}
|
||||||
return count;
|
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}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
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. */
|
/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */
|
||||||
export type TodayDashboardWidth = 1 | 2;
|
export type TodayDashboardWidth = 1 | 2;
|
||||||
@@ -49,6 +50,10 @@ export function sortDashboardCells<T extends { layout: TodayDashboardLayout; id:
|
|||||||
return [...cells].sort((a, b) => {
|
return [...cells].sort((a, b) => {
|
||||||
const byLayout = compareDashboardLayout(a.layout, b.layout);
|
const byLayout = compareDashboardLayout(a.layout, b.layout);
|
||||||
if (byLayout !== 0) return byLayout;
|
if (byLayout !== 0) return byLayout;
|
||||||
|
|
||||||
|
const byFeature = getTodayGadgetFeatureOrder(a.id) - getTodayGadgetFeatureOrder(b.id);
|
||||||
|
if (byFeature !== 0) return byFeature;
|
||||||
|
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -23,18 +23,29 @@ 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[];
|
||||||
tasksByProsthesis?: 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[];
|
||||||
|
casePartnersMonth?: TodayPartnerCasesBucket[];
|
||||||
efficiencyReport?: TodayChartBucket[];
|
efficiencyReport?: TodayChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user