Shitty gadgets removed. Some useful gadgets added.

This commit is contained in:
2026-07-11 03:12:56 +03:30
parent 2f8c9f0f4d
commit ed8ff61205
31 changed files with 1489 additions and 170 deletions

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class TodaySummaryQueryDto {
@ApiPropertyOptional({
@@ -17,4 +18,16 @@ export class TodaySummaryQueryDto {
@IsOptional()
@IsISO8601()
to?: string;
@ApiPropertyOptional({
description:
'Client UTC offset in minutes (same sign as Date.getTimezoneOffset negated). Used for hourly buckets.',
example: 210,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(-840)
@Max(840)
utcOffsetMinutes?: number;
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { StaffModule } from '../staff/staff.module';
import { TodayController } from './today.controller';
import { TodayService } from './today.service';
@Module({
imports: [StaffModule],
controllers: [TodayController],
providers: [TodayService],
})

View File

@@ -18,13 +18,33 @@ import {
normalizeCatalogLocale,
type CatalogLocale,
} from '../catalog/catalog-label.service';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
type ChartBucket = { code: string; label: string; count: number };
type StackedDayBucket = {
code: string;
label: string;
completed: number;
received: number;
};
type CaseCompletionChart = {
completed: number;
total: number;
percent: number;
};
type TodayCharts = {
treatmentMixWeek?: ChartBucket[];
tasksByWorkflowStep?: ChartBucket[];
appointmentsByProvider?: ChartBucket[];
caseCompletion?: CaseCompletionChart;
appointmentsWeekAll?: ChartBucket[];
appointmentsWeekMine?: ChartBucket[];
labTaskActivityWeek?: StackedDayBucket[];
inProgressTasksByProsthesis?: ChartBucket[];
};
type TodayActions = {
@@ -44,11 +64,13 @@ type TodayWidgets = {
draftTreatments?: { count: number };
labCasesPendingSend?: { count: number };
casesReceivedToday?: { count: number };
casesInProgress?: { count: number };
tasksInProgress?: { count: number };
importantTasks?: { count: number };
pendingConnections?: { count: number };
seats?: { used: number; limit: number | null; unlimited: boolean };
pendingStaffInvites?: { count: number };
providersWithoutWorkingHours?: { count: number };
};
@Injectable()
@@ -56,6 +78,7 @@ export class TodayService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly staffWorkingHoursService: StaffWorkingHoursService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -90,16 +113,15 @@ export class TodayService {
this.loadAppointmentsToday(organizationId, from, to, widgets),
);
tasks.push(
this.loadUpcomingAppointmentsToday(organizationId, from, to, actions),
this.loadAppointmentsByProvider(organizationId, from, to, charts),
);
}
if (
this.canViewPatients(membership.isOwner, permissionNames) ||
this.canViewAppointments(membership.isOwner, permissionNames)
) {
tasks.push(
this.loadPatientsToday(organizationId, from, to, widgets),
this.loadAppointmentsWeekAll(
organizationId,
to,
query.utcOffsetMinutes,
charts,
),
);
}
@@ -112,14 +134,53 @@ export class TodayService {
tasks.push(
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
);
tasks.push(
this.loadAppointmentsWeekMine(
organizationId,
userId,
to,
query.utcOffsetMinutes,
charts,
),
);
tasks.push(
this.loadUpcomingAppointmentsToday(
organizationId,
userId,
from,
to,
actions,
),
);
}
if (
this.canViewPatients(membership.isOwner, permissionNames) ||
this.canViewAppointments(membership.isOwner, permissionNames)
) {
tasks.push(
this.loadPatientsToday(organizationId, from, to, widgets),
);
}
if (this.canViewStaff(membership.isOwner, permissionNames)) {
tasks.push(
this.loadProvidersWithoutWorkingHours(organizationId, widgets),
);
}
}
if (orgType === 'LAB') {
const canViewLabWork =
this.canViewCases(membership.isOwner, permissionNames) ||
this.canViewTasks(membership.isOwner, permissionNames);
if (this.canViewCases(membership.isOwner, permissionNames)) {
tasks.push(
this.loadCasesReceivedToday(organizationId, from, to, widgets),
);
tasks.push(this.loadCasesInProgress(organizationId, widgets));
tasks.push(this.loadCaseCompletion(organizationId, charts));
}
if (this.canViewTasks(membership.isOwner, permissionNames)) {
@@ -127,6 +188,20 @@ export class TodayService {
tasks.push(this.loadImportantTasks(organizationId, widgets));
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
}
if (canViewLabWork) {
tasks.push(
this.loadLabTaskActivityWeek(
organizationId,
to,
query.utcOffsetMinutes,
charts,
),
);
tasks.push(
this.loadInProgressTasksByProsthesis(organizationId, locale, charts),
);
}
}
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
@@ -264,6 +339,7 @@ export class TodayService {
private async loadUpcomingAppointmentsToday(
organizationId: string,
providerUserId: string,
from: Date,
to: Date,
actions: TodayActions,
@@ -272,6 +348,7 @@ export class TodayService {
const items = await this.prisma.appointment.findMany({
where: {
organizationId,
providerUserId,
startAt: { lt: to },
endAt: { gt: now > from ? now : from },
},
@@ -279,7 +356,7 @@ export class TodayService {
patient: { select: { firstName: true, lastName: true } },
},
orderBy: { startAt: 'asc' },
take: 5,
take: 3,
});
actions.upcomingAppointmentsToday = items.map((appointment) => ({
@@ -448,6 +525,303 @@ export class TodayService {
widgets.pendingStaffInvites = { count };
}
private async loadAppointmentsByProvider(
organizationId: string,
from: Date,
to: Date,
charts: TodayCharts,
) {
const appointments = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
select: { providerUserId: true },
});
const countsByProvider = new Map<string, number>();
for (const appointment of appointments) {
countsByProvider.set(
appointment.providerUserId,
(countsByProvider.get(appointment.providerUserId) ?? 0) + 1,
);
}
if (countsByProvider.size === 0) {
charts.appointmentsByProvider = [];
return;
}
const sorted = [...countsByProvider.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
const users = await this.prisma.user.findMany({
where: { id: { in: sorted.map(([userId]) => userId) } },
select: { id: true, name: true },
});
const nameById = new Map(users.map((user) => [user.id, user.name]));
charts.appointmentsByProvider = sorted.map(([userId, count]) => ({
code: userId,
label: nameById.get(userId) ?? userId,
count,
}));
}
private async loadProvidersWithoutWorkingHours(
organizationId: string,
widgets: TodayWidgets,
) {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isOwner: false,
isActive: true,
permissions: {
some: {
permission: { name: 'TAB_TREATMENT_EDIT' },
},
},
},
select: { id: true },
});
if (members.length === 0) {
widgets.providersWithoutWorkingHours = { count: 0 };
return;
}
const scheduleBlocksByMembership =
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds(
members.map((member) => member.id),
);
const count = members.filter((member) => {
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
return blocks.length === 0;
}).length;
widgets.providersWithoutWorkingHours = { count };
}
private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) {
const cases = await this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
include: {
tasks: { select: { status: true } },
},
});
const count = cases.filter((labCase) => {
if (labCase.tasks.length === 0) return false;
const completed = labCase.tasks.filter(
(task) => task.status === LabTaskStatus.COMPLETED,
).length;
return completed < labCase.tasks.length;
}).length;
widgets.casesInProgress = { count };
}
private async loadCaseCompletion(labOrganizationId: string, charts: TodayCharts) {
const tasks = await this.prisma.labCaseTask.findMany({
where: {
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
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 = { completed, total, percent };
}
private async loadAppointmentsWeekAll(
organizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
charts: TodayCharts,
) {
charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries(
organizationId,
rangeEnd,
utcOffsetMinutes,
);
}
private async loadAppointmentsWeekMine(
organizationId: string,
providerUserId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
charts: TodayCharts,
) {
charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries(
organizationId,
rangeEnd,
utcOffsetMinutes,
providerUserId,
);
}
private async loadAppointmentsWeekSeries(
organizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
providerUserId?: string,
): Promise<ChartBucket[]> {
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
const weekEnd = rangeEnd;
const appointments = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { gte: weekStart, lt: weekEnd },
...(providerUserId ? { providerUserId } : {}),
},
select: { startAt: true },
});
const countsByDay = new Map<string, number>();
for (const bucket of dayBuckets) {
countsByDay.set(bucket.code, 0);
}
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
for (const appointment of appointments) {
const dayKey = localDayKeyFromDate(appointment.startAt, offsetMs);
if (countsByDay.has(dayKey)) {
countsByDay.set(dayKey, (countsByDay.get(dayKey) ?? 0) + 1);
}
}
return dayBuckets.map((bucket) => ({
code: bucket.code,
label: bucket.label,
count: countsByDay.get(bucket.code) ?? 0,
}));
}
private async loadLabTaskActivityWeek(
labOrganizationId: string,
rangeEnd: Date,
utcOffsetMinutes: number | undefined,
charts: TodayCharts,
) {
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
const weekEnd = rangeEnd;
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
const completedCounts = new Map<string, number>();
const receivedCounts = new Map<string, number>();
for (const bucket of dayBuckets) {
completedCounts.set(bucket.code, 0);
receivedCounts.set(bucket.code, 0);
}
const completedTasks = await this.prisma.labCaseTask.findMany({
where: {
status: LabTaskStatus.COMPLETED,
lastStatusChangedAt: { gte: weekStart, lt: weekEnd },
labCase: {
sends: { some: { organizationId: labOrganizationId } },
},
},
select: { lastStatusChangedAt: true },
});
for (const task of completedTasks) {
if (!task.lastStatusChangedAt) continue;
const dayKey = localDayKeyFromDate(task.lastStatusChangedAt, offsetMs);
if (completedCounts.has(dayKey)) {
completedCounts.set(dayKey, (completedCounts.get(dayKey) ?? 0) + 1);
}
}
const sends = await this.prisma.labCaseSend.findMany({
where: {
organizationId: labOrganizationId,
sentAt: { gte: weekStart, lt: weekEnd },
},
include: {
labCase: { select: { tasks: { select: { id: true } } } },
},
});
for (const send of sends) {
const dayKey = localDayKeyFromDate(send.sentAt, offsetMs);
if (receivedCounts.has(dayKey)) {
receivedCounts.set(
dayKey,
(receivedCounts.get(dayKey) ?? 0) + send.labCase.tasks.length,
);
}
}
charts.labTaskActivityWeek = dayBuckets.map((bucket) => ({
code: bucket.code,
label: bucket.label,
completed: completedCounts.get(bucket.code) ?? 0,
received: receivedCounts.get(bucket.code) ?? 0,
}));
}
private async loadInProgressTasksByProsthesis(
labOrganizationId: string,
locale: CatalogLocale,
charts: TodayCharts,
) {
const grouped = await this.prisma.labCaseTask.groupBy({
by: ['prosthesisTypeCode'],
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
_count: { _all: true },
});
const sorted = grouped
.map((row) => ({
code: row.prosthesisTypeCode,
count: aggregateCount(row._count),
}))
.sort((a, b) => b.count - a.count);
if (sorted.length === 0) {
charts.inProgressTasksByProsthesis = [];
return;
}
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
sorted.map((row) => row.code),
locale,
);
charts.inProgressTasksByProsthesis = sorted.map((row) => ({
code: row.code,
label: labels.get(row.code) ?? row.code,
count: row.count,
}));
}
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
return null;
@@ -567,3 +941,37 @@ function aggregateCount(
if (!count || count === true) return 0;
return count._all ?? 0;
}
type LocalDayBucket = { code: string; label: string; start: Date; end: Date };
function buildLastSevenLocalDayBuckets(
rangeEnd: Date,
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,
});
}
return buckets;
}
function localDayKeyFromDate(date: Date, offsetMs: number): string {
const localMs = date.getTime() + offsetMs;
const local = new Date(localMs);
const year = local.getUTCFullYear();
const month = String(local.getUTCMonth() + 1).padStart(2, '0');
const day = String(local.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}