Positioning, sizing and sorting of the gadgets improved.

This commit is contained in:
2026-07-11 22:32:37 +03:30
parent ed8ff61205
commit 83f6003a2b
35 changed files with 1615 additions and 678 deletions

View File

@@ -29,7 +29,7 @@ export class AppointmentsController {
@Get('column-providers')
@ApiOperation({
summary:
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.',
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or TAB_APPOINTMENTS_EDIT, or owner.',
})
columnProviders(
@Query() query: ColumnProvidersQueryDto,

View File

@@ -77,7 +77,10 @@ export class AppointmentsService {
}
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const { scopeToProvider } = await this.assertCanListAppointmentsForTreatment(
actorUserId,
organizationId,
);
const from = new Date(query.from);
const to = new Date(query.to);
@@ -95,6 +98,7 @@ export class AppointmentsService {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
...(scopeToProvider ? { providerUserId: actorUserId } : {}),
},
include: {
patient: {
@@ -256,18 +260,34 @@ export class AppointmentsService {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_READ')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
if (names.includes('TAB_TREATMENT_READ')) {
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to appointments');
}
private async assertCanListAppointmentsForTreatment(
userId: string,
organizationId: string,
) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) {
return { membership: m, scopeToProvider: false as const };
}
const names = m.permissions.map((p) => p.permission.name);
const canViewSchedule =
names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT');
const canViewTreatment =
names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT');
if (!canViewSchedule && !canViewTreatment) {
throw new ForbiddenException('You do not have access to appointments');
}
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
}
private async assertCanEditAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
@@ -280,9 +300,6 @@ export class AppointmentsService {
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot create or modify appointments');
}

View File

@@ -45,6 +45,7 @@ type TodayCharts = {
appointmentsWeekMine?: ChartBucket[];
labTaskActivityWeek?: StackedDayBucket[];
inProgressTasksByProsthesis?: ChartBucket[];
efficiencyReport?: ChartBucket[];
};
type TodayActions = {
@@ -57,6 +58,20 @@ type TodayActions = {
}>;
};
type TodaySubscriptionWidget = {
hasActivePlan: boolean;
planName: string | null;
seatsUsed: number;
seatsLimit: number | null;
seatsUnlimited: boolean;
seatsPercent: number;
periodStartAt: string;
periodEndAt: string | null;
periodTotalDays: number;
periodElapsedDays: number;
periodPercent: number;
};
type TodayWidgets = {
appointmentsToday?: { count: number };
patientsToday?: { count: number };
@@ -68,7 +83,6 @@ type TodayWidgets = {
tasksInProgress?: { count: number };
importantTasks?: { count: number };
pendingConnections?: { count: number };
seats?: { used: number; limit: number | null; unlimited: boolean };
pendingStaffInvites?: { count: number };
providersWithoutWorkingHours?: { count: number };
};
@@ -106,6 +120,7 @@ export class TodayService {
const charts: TodayCharts = {};
const actions: TodayActions = {};
const tasks: Promise<void>[] = [];
let subscription: TodaySubscriptionWidget | undefined;
if (orgType === 'CLINIC') {
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
@@ -210,8 +225,23 @@ export class TodayService {
);
}
if (membership.isOwner) {
if (orgType === 'CLINIC') {
tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts));
}
if (orgType === 'LAB') {
tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts));
}
tasks.push(
this.buildSubscriptionWidget(organizationId, membership.organization).then(
(value) => {
subscription = value;
},
),
);
}
if (this.canViewStaff(membership.isOwner, permissionNames)) {
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
}
@@ -226,6 +256,7 @@ export class TodayService {
widgets,
charts,
actions,
...(subscription ? { subscription } : {}),
},
};
}
@@ -356,7 +387,7 @@ export class TodayService {
patient: { select: { firstName: true, lastName: true } },
},
orderBy: { startAt: 'asc' },
take: 3,
take: 10,
});
actions.upcomingAppointmentsToday = items.map((appointment) => ({
@@ -481,25 +512,197 @@ export class TodayService {
widgets.pendingConnections = { count };
}
private async loadSeats(
private async getActiveEditAccessUserIds(
organizationId: string,
plan: { maxUsers: number } | null,
widgets: TodayWidgets,
editPermission: 'TAB_TREATMENT_EDIT' | 'TAB_TASKS_EDIT',
): Promise<string[]> {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isActive: true,
OR: [
{ isOwner: true },
{
isOwner: false,
permissions: {
some: { permission: { name: editPermission } },
},
},
],
},
select: { userId: true },
});
return members.map((member) => member.userId);
}
private async loadClinicEfficiencyReport(
organizationId: string,
rangeEnd: Date,
charts: TodayCharts,
) {
const used = await this.prisma.membership.count({
const eligibleUserIds = await this.getActiveEditAccessUserIds(
organizationId,
'TAB_TREATMENT_EDIT',
);
if (eligibleUserIds.length < 2) {
return;
}
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const grouped = await this.prisma.treatment.groupBy({
by: ['providerUserId'],
where: {
organizationId,
treatmentAt: { gte: monthStart, lt: rangeEnd },
providerUserId: { in: eligibleUserIds },
},
_count: { _all: true },
});
const countsByUser = new Map(
eligibleUserIds.map((userId) => [userId, 0]),
);
for (const row of grouped) {
countsByUser.set(row.providerUserId, aggregateCount(row._count));
}
const users = await this.prisma.user.findMany({
where: { id: { in: eligibleUserIds } },
select: { id: true, name: true },
});
const nameById = new Map(users.map((user) => [user.id, user.name]));
charts.efficiencyReport = eligibleUserIds
.map((userId) => ({
code: userId,
label: nameById.get(userId) ?? userId,
count: countsByUser.get(userId) ?? 0,
}))
.sort((a, b) => b.count - a.count);
}
private async loadLabEfficiencyReport(
labOrganizationId: string,
rangeEnd: Date,
charts: TodayCharts,
) {
const eligibleUserIds = await this.getActiveEditAccessUserIds(
labOrganizationId,
'TAB_TASKS_EDIT',
);
if (eligibleUserIds.length < 2) {
return;
}
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
by: ['changedByUserId'],
where: {
toStatus: LabTaskStatus.COMPLETED,
changedAt: { gte: monthStart, lt: rangeEnd },
changedByUserId: { in: eligibleUserIds },
task: {
labCase: {
sends: { some: { organizationId: labOrganizationId } },
},
},
},
_count: { _all: true },
});
const countsByUser = new Map(
eligibleUserIds.map((userId) => [userId, 0]),
);
for (const row of grouped) {
if (!row.changedByUserId) continue;
countsByUser.set(row.changedByUserId, aggregateCount(row._count));
}
const users = await this.prisma.user.findMany({
where: { id: { in: eligibleUserIds } },
select: { id: true, name: true },
});
const nameById = new Map(users.map((user) => [user.id, user.name]));
charts.efficiencyReport = eligibleUserIds
.map((userId) => ({
code: userId,
label: nameById.get(userId) ?? userId,
count: countsByUser.get(userId) ?? 0,
}))
.sort((a, b) => b.count - a.count);
}
private async buildSubscriptionWidget(
organizationId: string,
organization: {
createdAt: Date;
plan: { name: string; maxUsers: number } | null;
},
): Promise<TodaySubscriptionWidget> {
const seatsUsed = await this.prisma.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
const maxUsers = plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers);
const plan = organization.plan;
const periodStartAt = organization.createdAt.toISOString();
widgets.seats = {
used,
limit: unlimited ? null : maxUsers,
unlimited,
if (!plan) {
return {
hasActivePlan: false,
planName: null,
seatsUsed,
seatsLimit: null,
seatsUnlimited: false,
seatsPercent: 0,
periodStartAt,
periodEndAt: null,
periodTotalDays: 0,
periodElapsedDays: 0,
periodPercent: 0,
};
}
const maxUsers = plan.maxUsers;
const seatsUnlimited = isUnlimitedSeats(maxUsers);
const seatsLimit = seatsUnlimited ? null : maxUsers;
const seatsPercent =
seatsUnlimited || maxUsers <= 0
? 0
: Math.min(100, Math.round((seatsUsed / maxUsers) * 100));
const durationDays = plan.name === 'trial' ? 30 : 90;
const periodEnd = new Date(organization.createdAt);
periodEnd.setDate(periodEnd.getDate() + durationDays);
const periodEndAt = periodEnd.toISOString();
const totalMs = periodEnd.getTime() - organization.createdAt.getTime();
const elapsedMs = Math.min(
Math.max(0, Date.now() - organization.createdAt.getTime()),
totalMs,
);
const periodPercent =
totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0;
const periodElapsedDays = Math.min(
durationDays,
Math.floor(elapsedMs / 86_400_000),
);
return {
hasActivePlan: true,
planName: plan.name,
seatsUsed,
seatsLimit,
seatsUnlimited,
seatsPercent,
periodStartAt,
periodEndAt,
periodTotalDays: durationDays,
periodElapsedDays,
periodPercent,
};
}
@@ -885,12 +1088,7 @@ export class TodayService {
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
[
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
].includes(p),
['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'].includes(p),
);
}