improvement: some new gadgets added to dashboard. some new functionality added to existed gadgets.

This commit is contained in:
2026-07-14 03:06:56 +03:30
parent ec2b23f4b1
commit d383a4abc3
34 changed files with 944 additions and 191 deletions

View File

@@ -18,6 +18,7 @@ import {
type CatalogLocale,
} from '../catalog/catalog-label.service';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { startOfUtcDay } from '../../common/lab-case-due-date';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
type ChartBucket = { code: string; label: string; count: number };
@@ -52,6 +53,7 @@ type TodayCharts = {
appointmentsWeekMine?: ChartBucket[];
labTaskActivityWeek?: StackedDayBucket[];
casePartnersMonth?: PartnerCasesBucket[];
casesDueWeek?: ChartBucket[];
efficiencyReport?: ChartBucket[];
};
@@ -88,9 +90,11 @@ type TodayWidgets = {
casesInProgress?: { count: number };
tasksInProgress?: { count: number };
importantTasks?: { count: number };
overdueCases?: { count: number };
unassignedTasks?: { count: number };
pendingConnections?: { count: number };
pendingStaffInvites?: { count: number };
providersWithoutWorkingHours?: { count: number };
providersWithoutWorkingHours?: { count: number; membershipIds: string[] };
};
@Injectable()
@@ -225,6 +229,14 @@ export class TodayService {
);
tasks.push(this.loadCasesInProgress(organizationId, widgets));
tasks.push(this.loadCaseCompletion(organizationId, charts));
tasks.push(
this.loadCasesDueWeek(
organizationId,
from,
query.utcOffsetMinutes,
charts,
),
);
}
if (this.canEditCases(membership.isOwner, permissionNames)) {
@@ -243,6 +255,8 @@ export class TodayService {
if (this.canViewTasks(membership.isOwner, permissionNames)) {
tasks.push(this.loadTasksInProgress(organizationId, widgets));
tasks.push(this.loadImportantTasks(organizationId, widgets));
tasks.push(this.loadOverdueCases(organizationId, widgets));
tasks.push(this.loadUnassignedTasks(organizationId, widgets));
tasks.push(this.loadTasksByProsthesis(organizationId, locale, charts));
}
@@ -540,6 +554,32 @@ export class TodayService {
widgets.importantTasks = { count };
}
private async loadOverdueCases(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCase.count({
where: {
sentAt: { not: null },
dueDate: { not: null, lt: startOfUtcDay() },
sends: { some: { organizationId: labOrganizationId } },
tasks: { some: { status: LabTaskStatus.IN_PROGRESS } },
},
});
widgets.overdueCases = { count };
}
private async loadUnassignedTasks(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCaseTask.count({
where: {
status: LabTaskStatus.IN_PROGRESS,
assigneeUserId: null,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
widgets.unassignedTasks = { count };
}
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
const links = await this.prisma.organizationLink.findMany({
where: {
@@ -772,17 +812,47 @@ export class TodayService {
widgets.pendingStaffInvites = { count };
}
private async listTreatmentParticipatingMembers(organizationId: string) {
return this.prisma.membership.findMany({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
permissions: {
some: {
permission: { name: 'TAB_TREATMENT_EDIT' },
},
},
},
select: {
id: true,
isOwner: true,
user: { select: { id: true, name: true } },
},
});
}
private async loadAppointmentsByProvider(
organizationId: string,
from: Date,
to: Date,
charts: TodayCharts,
) {
const members = await this.listTreatmentParticipatingMembers(organizationId);
if (members.length === 0) {
charts.appointmentsByProvider = [];
return;
}
const participatingUserIds = new Set(members.map((member) => member.user.id));
const ownerMember = members.find((member) => member.isOwner);
const appointments = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
providerUserId: { in: [...participatingUserIds] },
},
select: { providerUserId: true },
});
@@ -795,25 +865,33 @@ export class TodayService {
);
}
if (countsByProvider.size === 0) {
charts.appointmentsByProvider = [];
return;
const rows = members.map((member) => ({
userId: member.user.id,
label: member.user.name,
count: countsByProvider.get(member.user.id) ?? 0,
isOwner: member.isOwner,
}));
const sorted = [...rows].sort((a, b) => b.count - a.count);
let top = sorted.slice(0, 8);
if (ownerMember) {
const ownerUserId = ownerMember.user.id;
const ownerInTop = top.some((row) => row.userId === ownerUserId);
if (!ownerInTop) {
const ownerRow = rows.find((row) => row.userId === ownerUserId)!;
if (top.length >= 8) {
top = [...top.slice(0, 7), ownerRow];
} else {
top = [...top, ownerRow];
}
}
}
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,
charts.appointmentsByProvider = top.map((row) => ({
code: row.userId,
label: row.label,
count: row.count,
}));
}
@@ -821,21 +899,10 @@ export class TodayService {
organizationId: string,
widgets: TodayWidgets,
) {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
permissions: {
some: {
permission: { name: 'TAB_TREATMENT_EDIT' },
},
},
},
select: { id: true },
});
const members = await this.listTreatmentParticipatingMembers(organizationId);
if (members.length === 0) {
widgets.providersWithoutWorkingHours = { count: 0 };
widgets.providersWithoutWorkingHours = { count: 0, membershipIds: [] };
return;
}
@@ -844,12 +911,52 @@ export class TodayService {
members.map((member) => member.id),
);
const count = members.filter((member) => {
const missingHoursMembers = members.filter((member) => {
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
return blocks.length === 0;
}).length;
});
widgets.providersWithoutWorkingHours = { count };
widgets.providersWithoutWorkingHours = {
count: missingHoursMembers.length,
membershipIds: missingHoursMembers.map((member) => member.id),
};
}
private async loadCasesDueWeek(
labOrganizationId: string,
rangeStart: Date,
utcOffsetMinutes: number | undefined,
charts: TodayCharts,
) {
const dayBuckets = buildNextSevenLocalDayBuckets(rangeStart, utcOffsetMinutes);
const weekStart = dayBuckets[0]?.start ?? rangeStart;
const weekEnd = dayBuckets[dayBuckets.length - 1]?.end ?? rangeStart;
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
const counts = new Map(dayBuckets.map((bucket) => [bucket.code, 0]));
const cases = await this.prisma.labCase.findMany({
where: {
dueDate: { not: null, gte: weekStart, lt: weekEnd },
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
tasks: { some: { status: LabTaskStatus.IN_PROGRESS } },
},
select: { dueDate: true },
});
for (const labCase of cases) {
if (!labCase.dueDate) continue;
const dayKey = localDayKeyFromDate(labCase.dueDate, offsetMs);
if (counts.has(dayKey)) {
counts.set(dayKey, (counts.get(dayKey) ?? 0) + 1);
}
}
charts.casesDueWeek = dayBuckets.map((bucket) => ({
code: bucket.code,
label: bucket.label,
count: counts.get(bucket.code) ?? 0,
}));
}
private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) {
@@ -1279,6 +1386,33 @@ function buildLastSevenLocalDayBuckets(
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 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) {
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,
});
}
return buckets;
}
function localDayKeyFromDate(date: Date, offsetMs: number): string {
const localMs = date.getTime() + offsetMs;
const local = new Date(localMs);