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

@@ -23,6 +23,7 @@
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
"prisma:wipe-app-data": "ts-node prisma/wipe-app-data.ts",
"prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
},
"prisma": {

View File

@@ -0,0 +1,112 @@
/**
* Dev-only: wipe all application data while keeping catalog / reference tables from seed.
*
* Preserved: organization_types, plans, features, permissions, treatment_types,
* lab_workflow_steps, prosthesis_types, prosthesis_type_steps, catalog_translations
*
* Usage: npm run prisma:wipe-app-data
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
import { existsSync, rmSync } from 'fs';
import path from 'path';
const envPath = path.join(__dirname, '..', '.env');
config({ path: envPath });
if (process.env.NODE_ENV === 'production') {
console.error('wipe-app-data is not allowed in production');
process.exit(1);
}
const prisma = new PrismaClient();
const CATALOG_TABLES = new Set([
'organization_types',
'plans',
'features',
'permissions',
'treatment_types',
'lab_workflow_steps',
'prosthesis_types',
'prosthesis_type_steps',
'catalog_translations',
]);
// FK-safe order: children before parents where CASCADE is not enough.
const TABLES_IN_ORDER = [
'phone_verification_codes',
'staff_working_hours_blocks',
'staff_working_hours_schedules',
'staff_invitations',
'membership_permissions',
'sessions',
'lab_case_task_status_events',
'lab_case_comments',
'lab_case_attachments',
'lab_case_tasks',
'lab_case_sends',
'lab_case_tooth_prosthesis',
'lab_case_details',
'lab_cases',
'treatment_detail_attachments',
'treatment_details',
'treatments',
'appointments',
'organization_links',
'organization_invitations',
'patients',
'memberships',
'organizations',
'users',
];
async function tableExists(table: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ exists: string | null }>>(
`SELECT to_regclass('public."${table}"')::text AS exists`,
);
return rows[0]?.exists != null;
}
async function main() {
console.log('🧹 Wiping application data (keeping catalog / reference tables)...');
const existing: string[] = [];
for (const table of TABLES_IN_ORDER) {
if (CATALOG_TABLES.has(table)) {
throw new Error(`Misconfigured wipe list includes catalog table: ${table}`);
}
if (await tableExists(table)) {
existing.push(table);
} else {
console.log(` - skipping "${table}" (does not exist yet)`);
}
}
if (existing.length === 0) {
console.log('No application tables found. Run `prisma migrate deploy` first.');
return;
}
const targets = existing.map((t) => `"${t}"`).join(', ');
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${targets} RESTART IDENTITY CASCADE`);
const uploadRoot = path.join(__dirname, '..', 'uploads');
if (existsSync(uploadRoot)) {
rmSync(uploadRoot, { recursive: true, force: true });
console.log(' - removed local uploads/ directory');
}
console.log('✅ Application data wiped.');
console.log(' Preserved catalog tables:', [...CATALOG_TABLES].sort().join(', '));
console.log(' Register a new user / org to start fresh testing.');
}
main()
.catch((e) => {
console.error('❌ Wipe failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

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),
);
}

View File

@@ -15,7 +15,6 @@
"jsx": "react",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,