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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user