Case completion and tasks completion gadgets added.

This commit is contained in:
2026-07-12 02:08:54 +03:30
parent d79aab907c
commit 0f1004a34a
10 changed files with 616 additions and 87 deletions

View File

@@ -30,20 +30,29 @@ type StackedDayBucket = {
received: number;
};
type CaseCompletionChart = {
type CompletionGaugeChart = {
completed: number;
total: number;
percent: number;
};
type PartnerCasesBucket = {
code: string;
label: string;
completed: number;
pending: number;
};
type TodayCharts = {
treatmentMixWeek?: ChartBucket[];
tasksByProsthesis?: ChartBucket[];
appointmentsByProvider?: ChartBucket[];
caseCompletion?: CaseCompletionChart;
caseCompletion?: CompletionGaugeChart;
treatmentPlanCompletion?: CompletionGaugeChart;
appointmentsWeekAll?: ChartBucket[];
appointmentsWeekMine?: ChartBucket[];
labTaskActivityWeek?: StackedDayBucket[];
casePartnersMonth?: PartnerCasesBucket[];
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)) {
tasks.push(
this.loadTreatmentsToday(organizationId, from, to, widgets),
@@ -198,6 +228,19 @@ export class TodayService {
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)) {
tasks.push(this.loadTasksInProgress(organizationId, widgets));
tasks.push(this.loadImportantTasks(organizationId, widgets));
@@ -515,10 +558,10 @@ export class TodayService {
widgets.pendingConnections = { count };
}
private async getActiveEditAccessUserIds(
private async getActiveEditAccessMembers(
organizationId: string,
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
): Promise<string[]> {
): Promise<Array<{ userId: string; isOwner: boolean }>> {
const members = await this.prisma.membership.findMany({
where: {
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(
@@ -544,13 +619,10 @@ export class TodayService {
rangeEnd: Date,
charts: TodayCharts,
) {
const eligibleUserIds = await this.getActiveEditAccessUserIds(
const members = await this.getActiveEditAccessMembers(
organizationId,
'TAB_TREATMENT_EDIT',
);
if (eligibleUserIds.length < 2) {
return;
}
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const grouped = await this.prisma.treatment.groupBy({
@@ -558,31 +630,22 @@ export class TodayService {
where: {
organizationId,
treatmentAt: { gte: monthStart, lt: rangeEnd },
providerUserId: { in: eligibleUserIds },
providerUserId: { in: members.map((member) => member.userId) },
},
_count: { _all: true },
});
const countsByUser = new Map(
eligibleUserIds.map((userId) => [userId, 0]),
members.map((member) => [member.userId, 0]),
);
for (const row of grouped) {
countsByUser.set(row.providerUserId, aggregateCount(row._count));
}
const users = await this.prisma.user.findMany({
where: { id: { in: eligibleUserIds } },
select: { id: true, name: true },
});
const nameById = new Map(users.map((user) => [user.id, user.name]));
charts.efficiencyReport = eligibleUserIds
.map((userId) => ({
code: userId,
label: nameById.get(userId) ?? userId,
count: countsByUser.get(userId) ?? 0,
}))
.sort((a, b) => b.count - a.count);
const report = await this.buildEfficiencyReportRows(members, countsByUser);
if (report) {
charts.efficiencyReport = report;
}
}
private async loadLabEfficiencyReport(
@@ -590,13 +653,10 @@ export class TodayService {
rangeEnd: Date,
charts: TodayCharts,
) {
const eligibleUserIds = await this.getActiveEditAccessUserIds(
const members = await this.getActiveEditAccessMembers(
labOrganizationId,
'TAB_TASKS_EDIT',
);
if (eligibleUserIds.length < 2) {
return;
}
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
@@ -604,7 +664,7 @@ export class TodayService {
where: {
toStatus: LabTaskStatus.COMPLETED,
changedAt: { gte: monthStart, lt: rangeEnd },
changedByUserId: { in: eligibleUserIds },
changedByUserId: { in: members.map((member) => member.userId) },
task: {
labCase: {
sends: { some: { organizationId: labOrganizationId } },
@@ -615,26 +675,17 @@ export class TodayService {
});
const countsByUser = new Map(
eligibleUserIds.map((userId) => [userId, 0]),
members.map((member) => [member.userId, 0]),
);
for (const row of grouped) {
if (!row.changedByUserId) continue;
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
}
const users = await this.prisma.user.findMany({
where: { id: { in: eligibleUserIds } },
select: { id: true, name: true },
});
const nameById = new Map(users.map((user) => [user.id, user.name]));
charts.efficiencyReport = eligibleUserIds
.map((userId) => ({
code: userId,
label: nameById.get(userId) ?? userId,
count: countsByUser.get(userId) ?? 0,
}))
.sort((a, b) => b.count - a.count);
const report = await this.buildEfficiencyReportRows(members, countsByUser);
if (report) {
charts.efficiencyReport = report;
}
}
private async buildSubscriptionWidget(
@@ -845,13 +896,42 @@ export class TodayService {
select: { status: true },
});
const total = tasks.length;
const completed = tasks.filter(
(task) => task.status === LabTaskStatus.COMPLETED,
).length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
charts.caseCompletion = this.buildCompletionGauge(
tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length,
tasks.length,
);
}
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(
@@ -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 {
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
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 {
if (isOwner) return true;
return names.includes('TAB_ORGANIZATIONS_EDIT');