Positioning, sizing and sorting of the gadgets improved.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user