Add Recharts bar charts to Today dashboard summary.

Extend the Today summary API with treatment mix and workflow task breakdowns, and render permission-aware chart cards on the Today page using Recharts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 00:34:38 +03:30
parent 22466490bf
commit 10959e3183
14 changed files with 754 additions and 22 deletions

View File

@@ -18,9 +18,14 @@ export class TodayController {
})
getSummary(
@Query() query: TodaySummaryQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string } },
) {
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
return this.todayService.getSummary(req.user.id, organizationId, query);
return this.todayService.getSummary(
req.user.id,
organizationId,
query,
req.user.language,
);
}
}

View File

@@ -3,7 +3,7 @@ import {
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { LabTaskStatus, LinkStatus } from '@prisma/client';
import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
isUnlimitedSeats,
@@ -13,8 +13,20 @@ import {
OrganizationTypeName,
ownerPermissionsForOrgType,
} from '../../common/organization-type';
import {
CatalogLabelService,
normalizeCatalogLocale,
type CatalogLocale,
} from '../catalog/catalog-label.service';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
type ChartBucket = { code: string; label: string; count: number };
type TodayCharts = {
treatmentMixWeek?: ChartBucket[];
tasksByWorkflowStep?: ChartBucket[];
};
type TodayWidgets = {
appointmentsToday?: { count: number };
patientsToday?: { count: number };
@@ -31,7 +43,10 @@ type TodayWidgets = {
@Injectable()
export class TodayService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -44,6 +59,7 @@ export class TodayService {
userId: string,
organizationId: string,
query: TodaySummaryQueryDto,
localeInput?: string | null,
) {
const membership = await this.getActiveMembership(userId, organizationId);
const permissionNames = this.resolvePermissionNames(membership);
@@ -51,8 +67,10 @@ export class TodayService {
const orgType = membership.organization.type.name as OrganizationTypeName;
const { from, to } = this.resolveDayRange(query);
const locale = normalizeCatalogLocale(localeInput);
const widgets: TodayWidgets = {};
const charts: TodayCharts = {};
const tasks: Promise<void>[] = [];
if (orgType === 'CLINIC') {
@@ -77,6 +95,9 @@ export class TodayService {
);
tasks.push(this.loadDraftTreatments(organizationId, widgets));
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
tasks.push(
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
);
}
}
@@ -90,6 +111,7 @@ export class TodayService {
if (this.canViewTasks(membership.isOwner, permissionNames)) {
tasks.push(this.loadTasksInProgress(organizationId, widgets));
tasks.push(this.loadImportantTasks(organizationId, widgets));
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
}
}
@@ -113,10 +135,81 @@ export class TodayService {
orgType,
range: { from: from.toISOString(), to: to.toISOString() },
widgets,
charts,
},
};
}
private async loadTreatmentMixWeek(
organizationId: string,
rangeEnd: Date,
locale: CatalogLocale,
charts: TodayCharts,
) {
const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000);
const grouped = await this.prisma.treatmentDetail.groupBy({
by: ['treatmentType'],
where: {
treatment: {
organizationId,
treatmentAt: { gte: weekStart, lt: rangeEnd },
},
},
_count: { _all: true },
});
const sorted = grouped
.map((row) => ({
code: row.treatmentType,
count: aggregateCount(row._count),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
if (sorted.length === 0) {
charts.treatmentMixWeek = [];
return;
}
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.TREATMENT_TYPE,
sorted.map((row) => row.code),
locale,
);
charts.treatmentMixWeek = sorted.map((row) => ({
code: row.code,
label: labels.get(row.code) ?? row.code,
count: row.count,
}));
}
private async loadTasksByWorkflowStep(
labOrganizationId: string,
charts: TodayCharts,
) {
const grouped = await this.prisma.labCaseTask.groupBy({
by: ['workflowStepCode', 'stepLabel'],
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
_count: { _all: true },
});
charts.tasksByWorkflowStep = grouped
.map((row) => ({
code: row.workflowStepCode,
label: row.stepLabel,
count: aggregateCount(row._count),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
}
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
if (query.from && query.to) {
const from = new Date(query.from);
@@ -423,3 +516,10 @@ export class TodayService {
);
}
}
function aggregateCount(
count: true | { _all?: number } | undefined,
): number {
if (!count || count === true) return 0;
return count._all ?? 0;
}