improvement: time period dropdown added to appropriate dashboard charts.

This commit is contained in:
2026-07-19 00:37:59 +03:30
parent 538121d653
commit db515f9630
18 changed files with 557 additions and 95 deletions

View File

@@ -1,7 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsIn, IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
const WEEK_MONTH = ['week', 'month'] as const;
const WEEK_MONTH_YEAR = ['week', 'month', 'year'] as const;
export class TodaySummaryQueryDto {
@ApiPropertyOptional({
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
@@ -30,4 +33,39 @@ export class TodaySummaryQueryDto {
@Min(-840)
@Max(840)
utcOffsetMinutes?: number;
@ApiPropertyOptional({ enum: WEEK_MONTH })
@IsOptional()
@IsIn(WEEK_MONTH)
appointmentsWeekPeriod?: 'week' | 'month';
@ApiPropertyOptional({ enum: WEEK_MONTH })
@IsOptional()
@IsIn(WEEK_MONTH)
appointmentsWeekMinePeriod?: 'week' | 'month';
@ApiPropertyOptional({ enum: WEEK_MONTH })
@IsOptional()
@IsIn(WEEK_MONTH)
labTaskActivityPeriod?: 'week' | 'month';
@ApiPropertyOptional({ enum: WEEK_MONTH })
@IsOptional()
@IsIn(WEEK_MONTH)
casesDuePeriod?: 'week' | 'month';
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
@IsOptional()
@IsIn(WEEK_MONTH_YEAR)
treatmentMixPeriod?: 'week' | 'month' | 'year';
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
@IsOptional()
@IsIn(WEEK_MONTH_YEAR)
casePartnersPeriod?: 'week' | 'month' | 'year';
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
@IsOptional()
@IsIn(WEEK_MONTH_YEAR)
efficiencyPeriod?: 'week' | 'month' | 'year';
}

View File

@@ -145,6 +145,7 @@ export class TodayService {
organizationId,
to,
query.utcOffsetMinutes,
resolveWeekMonthPeriod(query.appointmentsWeekPeriod, 'week'),
charts,
),
);
@@ -158,6 +159,7 @@ export class TodayService {
userId,
!membership.isOwner,
to,
resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'),
charts,
),
);
@@ -177,7 +179,13 @@ export class TodayService {
);
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
tasks.push(
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
this.loadTreatmentMixWeek(
organizationId,
to,
locale,
resolveWeekMonthYearPeriod(query.treatmentMixPeriod, 'week'),
charts,
),
);
}
@@ -197,6 +205,7 @@ export class TodayService {
userId,
to,
query.utcOffsetMinutes,
resolveWeekMonthPeriod(query.appointmentsWeekMinePeriod, 'week'),
charts,
),
);
@@ -234,6 +243,7 @@ export class TodayService {
organizationId,
from,
query.utcOffsetMinutes,
resolveWeekMonthPeriod(query.casesDuePeriod, 'week'),
charts,
),
);
@@ -247,6 +257,7 @@ export class TodayService {
userId,
false,
to,
resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'),
charts,
),
);
@@ -266,6 +277,7 @@ export class TodayService {
organizationId,
to,
query.utcOffsetMinutes,
resolveWeekMonthPeriod(query.labTaskActivityPeriod, 'week'),
charts,
),
);
@@ -279,11 +291,19 @@ export class TodayService {
}
if (membership.isOwner) {
const efficiencyPeriod = resolveWeekMonthYearPeriod(
query.efficiencyPeriod,
'month',
);
if (orgType === 'CLINIC') {
tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts));
tasks.push(
this.loadClinicEfficiencyReport(organizationId, to, efficiencyPeriod, charts),
);
}
if (orgType === 'LAB') {
tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts));
tasks.push(
this.loadLabEfficiencyReport(organizationId, to, efficiencyPeriod, charts),
);
}
tasks.push(
this.buildSubscriptionWidget(organizationId, membership.organization).then(
@@ -318,15 +338,18 @@ export class TodayService {
organizationId: string,
rangeEnd: Date,
locale: CatalogLocale,
period: ChartPeriodWeekMonthYear,
charts: TodayCharts,
) {
const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000);
const windowStart = new Date(
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
);
const grouped = await this.prisma.treatmentDetail.groupBy({
by: ['treatmentType'],
where: {
treatment: {
organizationId,
treatmentAt: { gte: weekStart, lt: rangeEnd },
treatmentAt: { gte: windowStart, lt: rangeEnd },
},
},
_count: { _all: true },
@@ -647,6 +670,7 @@ export class TodayService {
private async loadClinicEfficiencyReport(
organizationId: string,
rangeEnd: Date,
period: ChartPeriodWeekMonthYear,
charts: TodayCharts,
) {
const members = await this.getActiveEditAccessMembers(
@@ -654,12 +678,14 @@ export class TodayService {
'TAB_TREATMENT_EDIT',
);
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const windowStart = new Date(
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
);
const grouped = await this.prisma.treatment.groupBy({
by: ['providerUserId'],
where: {
organizationId,
treatmentAt: { gte: monthStart, lt: rangeEnd },
treatmentAt: { gte: windowStart, lt: rangeEnd },
providerUserId: { in: members.map((member) => member.userId) },
},
_count: { _all: true },
@@ -681,6 +707,7 @@ export class TodayService {
private async loadLabEfficiencyReport(
labOrganizationId: string,
rangeEnd: Date,
period: ChartPeriodWeekMonthYear,
charts: TodayCharts,
) {
const members = await this.getActiveEditAccessMembers(
@@ -688,12 +715,14 @@ export class TodayService {
'TAB_TASKS_EDIT',
);
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const windowStart = new Date(
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
);
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
by: ['changedByUserId'],
where: {
toStatus: LabTaskStatus.COMPLETED,
changedAt: { gte: monthStart, lt: rangeEnd },
changedAt: { gte: windowStart, lt: rangeEnd },
changedByUserId: { in: members.map((member) => member.userId) },
task: {
labCase: {
@@ -926,9 +955,15 @@ export class TodayService {
labOrganizationId: string,
rangeStart: Date,
utcOffsetMinutes: number | undefined,
period: ChartPeriodWeekMonth,
charts: TodayCharts,
) {
const dayBuckets = buildNextSevenLocalDayBuckets(rangeStart, utcOffsetMinutes);
const dayBuckets = buildLocalDayBuckets(
rangeStart,
daysForChartPeriod(period),
'forward',
utcOffsetMinutes,
);
const weekStart = dayBuckets[0]?.start ?? rangeStart;
const weekEnd = dayBuckets[dayBuckets.length - 1]?.end ?? rangeStart;
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
@@ -1034,12 +1069,14 @@ export class TodayService {
organizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
period: ChartPeriodWeekMonth,
charts: TodayCharts,
) {
charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries(
organizationId,
rangeEnd,
utcOffsetMinutes,
period,
);
}
@@ -1048,12 +1085,14 @@ export class TodayService {
providerUserId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
period: ChartPeriodWeekMonth,
charts: TodayCharts,
) {
charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries(
organizationId,
rangeEnd,
utcOffsetMinutes,
period,
providerUserId,
);
}
@@ -1062,9 +1101,15 @@ export class TodayService {
organizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
period: ChartPeriodWeekMonth,
providerUserId?: string,
): Promise<ChartBucket[]> {
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
const dayBuckets = buildLocalDayBuckets(
rangeEnd,
daysForChartPeriod(period),
'lookback',
utcOffsetMinutes,
);
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
const weekEnd = rangeEnd;
@@ -1101,9 +1146,15 @@ export class TodayService {
labOrganizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
period: ChartPeriodWeekMonth,
charts: TodayCharts,
) {
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
const dayBuckets = buildLocalDayBuckets(
rangeEnd,
daysForChartPeriod(period),
'lookback',
utcOffsetMinutes,
);
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
const weekEnd = rangeEnd;
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
@@ -1168,9 +1219,12 @@ export class TodayService {
userId: string,
scopeToUser: boolean,
rangeEnd: Date,
period: ChartPeriodWeekMonthYear,
charts: TodayCharts,
) {
const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const rangeStart = new Date(
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
);
const cases = await this.prisma.labCase.findMany({
where: {
@@ -1363,51 +1417,64 @@ function aggregateCount(
type LocalDayBucket = { code: string; label: string; start: Date; end: Date };
function buildLastSevenLocalDayBuckets(
rangeEnd: Date,
type ChartPeriodWeekMonth = 'week' | 'month';
type ChartPeriodWeekMonthYear = 'week' | 'month' | 'year';
function daysForChartPeriod(period: ChartPeriodWeekMonthYear): 7 | 30 | 365 {
switch (period) {
case 'month':
return 30;
case 'year':
return 365;
default:
return 7;
}
}
function resolveWeekMonthPeriod(
value: string | undefined,
fallback: ChartPeriodWeekMonth,
): ChartPeriodWeekMonth {
return value === 'week' || value === 'month' ? value : fallback;
}
function resolveWeekMonthYearPeriod(
value: string | undefined,
fallback: ChartPeriodWeekMonthYear,
): ChartPeriodWeekMonthYear {
return value === 'week' || value === 'month' || value === 'year' ? value : fallback;
}
function buildLocalDayBuckets(
anchor: Date,
count: number,
direction: 'lookback' | 'forward',
utcOffsetMinutes?: number,
): LocalDayBucket[] {
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
const dayMs = 86_400_000;
const buckets: LocalDayBucket[] = [];
for (let index = 0; index < 7; index += 1) {
const start = new Date(rangeEnd.getTime() - (7 - index) * dayMs);
const end = new Date(start.getTime() + dayMs);
const code = localDayKeyFromDate(start, offsetMs);
buckets.push({
code,
label: code,
start,
end,
});
if (direction === 'lookback') {
for (let index = 0; index < count; index += 1) {
const start = new Date(anchor.getTime() - (count - index) * dayMs);
const end = new Date(start.getTime() + dayMs);
const code = localDayKeyFromDate(start, offsetMs);
buckets.push({ code, label: code, start, end });
}
return buckets;
}
return buckets;
}
function buildNextSevenLocalDayBuckets(
rangeStart: Date,
utcOffsetMinutes?: number,
): LocalDayBucket[] {
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
const dayMs = 86_400_000;
const localMs = rangeStart.getTime() + offsetMs;
const localMs = anchor.getTime() + offsetMs;
const local = new Date(localMs);
local.setUTCHours(0, 0, 0, 0);
const dayStart = new Date(local.getTime() - offsetMs);
const buckets: LocalDayBucket[] = [];
for (let index = 0; index < 7; index += 1) {
for (let index = 0; index < count; index += 1) {
const start = new Date(dayStart.getTime() + index * dayMs);
const end = new Date(start.getTime() + dayMs);
const code = localDayKeyFromDate(start, offsetMs);
buckets.push({
code,
label: code,
start,
end,
});
buckets.push({ code, label: code, start, end });
}
return buckets;