Shitty gadgets removed. Some useful gadgets added.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user