Add Today dashboard foundation with permission-aware KPI summary API.
Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
import { TodayModule } from './modules/today/today.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
|
||||
LabCaseCommentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
20
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
20
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
export class TodaySummaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
|
||||
example: '2026-07-10T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'End of the local day range (ISO 8601, exclusive). Defaults to next UTC midnight.',
|
||||
example: '2026-07-11T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
to?: string;
|
||||
}
|
||||
26
backend/src/modules/today/today.controller.ts
Normal file
26
backend/src/modules/today/today.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@ApiTags('today')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('today')
|
||||
export class TodayController {
|
||||
constructor(private readonly todayService: TodayService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permission-aware dashboard summary for the Today tab (TAB_TODAY_READ or owner)',
|
||||
})
|
||||
getSummary(
|
||||
@Query() query: TodaySummaryQueryDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
|
||||
return this.todayService.getSummary(req.user.id, organizationId, query);
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/today/today.module.ts
Normal file
9
backend/src/modules/today/today.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TodayController } from './today.controller';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TodayController],
|
||||
providers: [TodayService],
|
||||
})
|
||||
export class TodayModule {}
|
||||
425
backend/src/modules/today/today.service.ts
Normal file
425
backend/src/modules/today/today.service.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, LinkStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
isUnlimitedSeats,
|
||||
normalizeTabPermissions,
|
||||
} from '../../common/permissions';
|
||||
import {
|
||||
OrganizationTypeName,
|
||||
ownerPermissionsForOrgType,
|
||||
} from '../../common/organization-type';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
|
||||
type TodayWidgets = {
|
||||
appointmentsToday?: { count: number };
|
||||
patientsToday?: { count: number };
|
||||
treatmentsToday?: { count: number };
|
||||
draftTreatments?: { count: number };
|
||||
labCasesPendingSend?: { count: number };
|
||||
casesReceivedToday?: { count: number };
|
||||
tasksInProgress?: { count: number };
|
||||
importantTasks?: { count: number };
|
||||
pendingConnections?: { count: number };
|
||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
||||
pendingStaffInvites?: { count: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TodayService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async getSummary(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
query: TodaySummaryQueryDto,
|
||||
) {
|
||||
const membership = await this.getActiveMembership(userId, organizationId);
|
||||
const permissionNames = this.resolvePermissionNames(membership);
|
||||
this.assertCanViewToday(membership.isOwner, permissionNames);
|
||||
|
||||
const orgType = membership.organization.type.name as OrganizationTypeName;
|
||||
const { from, to } = this.resolveDayRange(query);
|
||||
|
||||
const widgets: TodayWidgets = {};
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadAppointmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.canViewPatients(membership.isOwner, permissionNames) ||
|
||||
this.canViewAppointments(membership.isOwner, permissionNames)
|
||||
) {
|
||||
tasks.push(
|
||||
this.loadPatientsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadTreatmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
tasks.push(this.loadDraftTreatments(organizationId, widgets));
|
||||
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
||||
}
|
||||
}
|
||||
|
||||
if (orgType === 'LAB') {
|
||||
if (this.canViewCases(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadCasesReceivedToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
||||
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadPendingConnections(organizationId, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
|
||||
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
orgType,
|
||||
range: { from: from.toISOString(), to: to.toISOString() },
|
||||
widgets,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
|
||||
if (query.from && query.to) {
|
||||
const from = new Date(query.from);
|
||||
const to = new Date(query.to);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0),
|
||||
);
|
||||
const to = new Date(from.getTime() + 86_400_000);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
private async loadAppointmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.appointment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
});
|
||||
widgets.appointmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadPatientsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const rows = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
select: { patientId: true },
|
||||
distinct: ['patientId'],
|
||||
});
|
||||
widgets.patientsToday = { count: rows.length };
|
||||
}
|
||||
|
||||
private async loadTreatmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: from, lt: to },
|
||||
},
|
||||
});
|
||||
widgets.treatmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
details: { none: {} },
|
||||
},
|
||||
});
|
||||
widgets.draftTreatments = { count };
|
||||
}
|
||||
|
||||
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: null,
|
||||
destinationOrganizationId: { not: null },
|
||||
treatment: { organizationId },
|
||||
},
|
||||
});
|
||||
widgets.labCasesPendingSend = { count };
|
||||
}
|
||||
|
||||
private async loadCasesReceivedToday(
|
||||
labOrganizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: { gte: from, lt: to },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
});
|
||||
widgets.casesReceivedToday = { count };
|
||||
}
|
||||
|
||||
private async loadTasksInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.tasksInProgress = { count };
|
||||
}
|
||||
|
||||
private async loadImportantTasks(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
isImportant: true,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.importantTasks = { count };
|
||||
}
|
||||
|
||||
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
|
||||
const links = await this.prisma.organizationLink.findMany({
|
||||
where: {
|
||||
status: LinkStatus.PENDING,
|
||||
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
|
||||
},
|
||||
select: { sharedDataTypes: true },
|
||||
});
|
||||
|
||||
const count = links.filter((link) => {
|
||||
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
|
||||
return requesterOrgId !== null && requesterOrgId !== organizationId;
|
||||
}).length;
|
||||
|
||||
widgets.pendingConnections = { count };
|
||||
}
|
||||
|
||||
private async loadSeats(
|
||||
organizationId: string,
|
||||
plan: { maxUsers: number } | null,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const used = await this.prisma.membership.count({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const maxUsers = plan?.maxUsers ?? 0;
|
||||
const unlimited = isUnlimitedSeats(maxUsers);
|
||||
|
||||
widgets.seats = {
|
||||
used,
|
||||
limit: unlimited ? null : maxUsers,
|
||||
unlimited,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPendingStaffInvites(organizationId: string, widgets: TodayWidgets) {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: { organizationId, isOwner: false, isActive: false },
|
||||
include: {
|
||||
invitations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const count = members.filter((member) => {
|
||||
const invitation = member.invitations[0];
|
||||
if (!invitation || invitation.acceptedAt || invitation.revokedAt) {
|
||||
return false;
|
||||
}
|
||||
return invitation.expiresAt.getTime() > Date.now();
|
||||
}).length;
|
||||
|
||||
widgets.pendingStaffInvites = { count };
|
||||
}
|
||||
|
||||
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
||||
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const requester = (sharedDataTypes as { requesterOrganizationId?: unknown })
|
||||
.requesterOrganizationId;
|
||||
return typeof requester === 'string' ? requester : null;
|
||||
}
|
||||
|
||||
private async getActiveMembership(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
type: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private resolvePermissionNames(membership: {
|
||||
isOwner: boolean;
|
||||
organization: {
|
||||
planId: string | null;
|
||||
type: { name: string };
|
||||
};
|
||||
permissions: { permission: { name: string } }[];
|
||||
}): string[] {
|
||||
if (membership.isOwner) {
|
||||
const orgType = (membership.organization.type.name === 'LAB'
|
||||
? 'LAB'
|
||||
: 'CLINIC') as OrganizationTypeName;
|
||||
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
|
||||
}
|
||||
return normalizeTabPermissions(
|
||||
membership.permissions.map((p) => p.permission.name),
|
||||
);
|
||||
}
|
||||
|
||||
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
|
||||
if (isOwner) return;
|
||||
if (!permissionNames.includes('TAB_TODAY_READ')) {
|
||||
throw new ForbiddenException('You do not have access to Today');
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewPatients(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewCases(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_CASES_READ', 'TAB_CASES_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTasks(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.includes('TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
private canViewStaff(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_STAFF_READ', 'TAB_STAFF_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,10 +194,20 @@
|
||||
"noSubscriptionNotice": "This organization does not have an active subscription yet.",
|
||||
"choosePlanLink": "Choose a plan",
|
||||
"noSubscriptionCta": "to start the purchase process.",
|
||||
"cardTodaysAppointments": "Today's Appointments",
|
||||
"cardActivePatients": "Active Patients",
|
||||
"cardNewLabCase": "New Lab Case",
|
||||
"cardTodayInvoices": "Today invoices"
|
||||
"noWidgets": "No dashboard metrics are available for your current permissions.",
|
||||
"loadError": "Could not load dashboard metrics.",
|
||||
"seatsUnlimited": "Unlimited plan",
|
||||
"widgetAppointmentsToday": "Today's Appointments",
|
||||
"widgetPatientsToday": "Patients Today",
|
||||
"widgetTreatmentsToday": "Treatments Today",
|
||||
"widgetDraftTreatments": "Draft Treatments",
|
||||
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
|
||||
"widgetCasesReceivedToday": "Cases Received Today",
|
||||
"widgetTasksInProgress": "Tasks In Progress",
|
||||
"widgetImportantTasks": "Important Tasks",
|
||||
"widgetPendingConnections": "Pending Connections",
|
||||
"widgetSeats": "Seat Usage",
|
||||
"widgetPendingStaffInvites": "Pending Staff Invites"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "Redirecting…",
|
||||
|
||||
@@ -194,10 +194,20 @@
|
||||
"noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.",
|
||||
"choosePlanLink": "انتخاب طرح",
|
||||
"noSubscriptionCta": "برای شروع فرآیند خرید.",
|
||||
"cardTodaysAppointments": "نوبتهای امروز",
|
||||
"cardActivePatients": "بیماران فعال",
|
||||
"cardNewLabCase": "پرونده جدید لابراتوار",
|
||||
"cardTodayInvoices": "صورتحسابهای امروز"
|
||||
"noWidgets": "هیچ معیاری برای دسترسی فعلی شما در دسترس نیست.",
|
||||
"loadError": "بارگذاری معیارهای داشبورد ناموفق بود.",
|
||||
"seatsUnlimited": "طرح نامحدود",
|
||||
"widgetAppointmentsToday": "نوبتهای امروز",
|
||||
"widgetPatientsToday": "بیماران امروز",
|
||||
"widgetTreatmentsToday": "درمانهای امروز",
|
||||
"widgetDraftTreatments": "درمانهای پیشنویس",
|
||||
"widgetLabCasesPendingSend": "پروندههای در انتظار ارسال",
|
||||
"widgetCasesReceivedToday": "پروندههای دریافتی امروز",
|
||||
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||
"widgetImportantTasks": "وظایف مهم",
|
||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||
"widgetSeats": "استفاده از صندلی",
|
||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "در حال انتقال...",
|
||||
|
||||
@@ -194,10 +194,20 @@
|
||||
"noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.",
|
||||
"choosePlanLink": "Kies een abonnement",
|
||||
"noSubscriptionCta": "om het aankoopproces te starten.",
|
||||
"cardTodaysAppointments": "Afspraken van vandaag",
|
||||
"cardActivePatients": "Actieve patiënten",
|
||||
"cardNewLabCase": "Nieuwe laboratoriumcase",
|
||||
"cardTodayInvoices": "Facturen van vandaag"
|
||||
"noWidgets": "Geen dashboardstatistieken beschikbaar voor uw huidige rechten.",
|
||||
"loadError": "Dashboardstatistieken konden niet worden geladen.",
|
||||
"seatsUnlimited": "Onbeperkt abonnement",
|
||||
"widgetAppointmentsToday": "Afspraken van vandaag",
|
||||
"widgetPatientsToday": "Patiënten vandaag",
|
||||
"widgetTreatmentsToday": "Behandelingen vandaag",
|
||||
"widgetDraftTreatments": "Conceptbehandelingen",
|
||||
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
|
||||
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
|
||||
"widgetTasksInProgress": "Taken in uitvoering",
|
||||
"widgetImportantTasks": "Belangrijke taken",
|
||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||
"widgetSeats": "Zitplaatsgebruik",
|
||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "Bezig met doorsturen...",
|
||||
|
||||
@@ -186,10 +186,20 @@ const en = {
|
||||
noSubscriptionNotice: 'This organization does not have an active subscription yet.',
|
||||
choosePlanLink: 'Choose a plan',
|
||||
noSubscriptionCta: 'to start the purchase process.',
|
||||
cardTodaysAppointments: "Today's Appointments",
|
||||
cardActivePatients: 'Active Patients',
|
||||
cardNewLabCase: 'New Lab Case',
|
||||
cardTodayInvoices: 'Today invoices',
|
||||
noWidgets: 'No dashboard metrics are available for your current permissions.',
|
||||
loadError: 'Could not load dashboard metrics.',
|
||||
seatsUnlimited: 'Unlimited plan',
|
||||
widgetAppointmentsToday: "Today's Appointments",
|
||||
widgetPatientsToday: 'Patients Today',
|
||||
widgetTreatmentsToday: 'Treatments Today',
|
||||
widgetDraftTreatments: 'Draft Treatments',
|
||||
widgetLabCasesPendingSend: 'Lab Cases Pending Send',
|
||||
widgetCasesReceivedToday: 'Cases Received Today',
|
||||
widgetTasksInProgress: 'Tasks In Progress',
|
||||
widgetImportantTasks: 'Important Tasks',
|
||||
widgetPendingConnections: 'Pending Connections',
|
||||
widgetSeats: 'Seat Usage',
|
||||
widgetPendingStaffInvites: 'Pending Staff Invites',
|
||||
},
|
||||
staff: {
|
||||
redirecting: 'Redirecting…',
|
||||
|
||||
@@ -3,22 +3,23 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
|
||||
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
|
||||
export default function TodayPage() {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const { data, loading, error } = useTodaySummary(Boolean(currentOrganization?.id));
|
||||
const showNoSubscriptionNotice =
|
||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
{t('welcomeBack')}
|
||||
</h1>
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
|
||||
|
||||
{showNoSubscriptionNotice && (
|
||||
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<p className="text-sm text-amber-200">
|
||||
{t('noSubscriptionNotice')}{' '}
|
||||
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
|
||||
@@ -29,27 +30,15 @@ export default function TodayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">12</p>
|
||||
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">675</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">5</p>
|
||||
<p className="text-xs text-text-muted mt-1">35 ↑</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">1200$</p>
|
||||
<p className="text-xs text-text-muted mt-1">21,300 $</p>
|
||||
</Card>
|
||||
{error ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4">
|
||||
<p className="text-sm text-badge-danger-fg">
|
||||
{formatApiErrorMessage(error, t('loadError'))}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TodayKpiGrid widgets={data?.widgets ?? {}} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
frontend/src/components/today/KpiCard.tsx
Normal file
47
frontend/src/components/today/KpiCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import type { KpiCardColor } from '@/components/today/widget-registry';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
const colorClasses: Record<KpiCardColor, string> = {
|
||||
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
|
||||
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
|
||||
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
|
||||
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
|
||||
purple: '!bg-purpose-consultation-bg !text-purpose-consultation-fg !border-purpose-consultation-border',
|
||||
default: '',
|
||||
};
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle?: string | null;
|
||||
icon?: LucideIcon;
|
||||
color?: KpiCardColor;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
color = 'default',
|
||||
loading = false,
|
||||
}: KpiCardProps) {
|
||||
return (
|
||||
<Card className={colorClasses[color]}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{Icon ? <Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden /> : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
)}
|
||||
{subtitle ? (
|
||||
<p className="text-xs opacity-80 mt-1">{subtitle}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/today/TodayKpiGrid.tsx
Normal file
51
frontend/src/components/today/TodayKpiGrid.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { KpiCard } from '@/components/today/KpiCard';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import type { TodaySummaryWidgets } from '@/types/today';
|
||||
|
||||
interface TodayKpiGridProps {
|
||||
widgets: TodaySummaryWidgets;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const definitions = loading
|
||||
? getEligibleTodayKpis(currentOrganization)
|
||||
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||
|
||||
if (!loading && definitions.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{definitions.map((definition) => {
|
||||
const value = definition.formatValue(widgets) ?? '—';
|
||||
const subtitleKey = definition.formatSubtitle?.(widgets);
|
||||
const subtitle =
|
||||
subtitleKey === 'unlimited'
|
||||
? t('seatsUnlimited')
|
||||
: definition.formatSubtitle?.(widgets);
|
||||
|
||||
return (
|
||||
<KpiCard
|
||||
key={definition.key}
|
||||
title={t(definition.titleKey)}
|
||||
value={value}
|
||||
subtitle={subtitle}
|
||||
icon={definition.icon}
|
||||
color={definition.color}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/components/today/widget-registry.ts
Normal file
219
frontend/src/components/today/widget-registry.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
Stethoscope,
|
||||
UserCog,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canEditStaff,
|
||||
canViewCases,
|
||||
canViewStaff,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
hasPermission,
|
||||
type OrgTypeName,
|
||||
} from '@/components/shared/permissions';
|
||||
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
|
||||
|
||||
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
|
||||
|
||||
export interface TodayKpiDefinition {
|
||||
key: TodayWidgetKey;
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
color: KpiCardColor;
|
||||
orgTypes: OrgTypeName[];
|
||||
isVisible: (org: Organization | null) => boolean;
|
||||
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
||||
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
||||
}
|
||||
|
||||
function countWidget(
|
||||
widgets: TodaySummaryWidgets,
|
||||
key: TodayWidgetKey,
|
||||
): number | null {
|
||||
const value = widgets[key];
|
||||
if (!value || !('count' in value)) return null;
|
||||
return value.count;
|
||||
}
|
||||
|
||||
function canManageOrganizations(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
function canViewPatients(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
return (
|
||||
hasPermission(org, 'TAB_PATIENTS_READ') ||
|
||||
hasPermission(org, 'TAB_PATIENTS_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
{
|
||||
key: 'appointmentsToday',
|
||||
titleKey: 'widgetAppointmentsToday',
|
||||
icon: CalendarDays,
|
||||
color: 'blue',
|
||||
orgTypes: ['CLINIC'],
|
||||
isVisible: (org) => canAccessAppointmentsSection(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'appointmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'patientsToday',
|
||||
titleKey: 'widgetPatientsToday',
|
||||
icon: Users,
|
||||
color: 'green',
|
||||
orgTypes: ['CLINIC'],
|
||||
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'patientsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'treatmentsToday',
|
||||
titleKey: 'widgetTreatmentsToday',
|
||||
icon: Stethoscope,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC'],
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'treatmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'draftTreatments',
|
||||
titleKey: 'widgetDraftTreatments',
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC'],
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'draftTreatments');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'labCasesPendingSend',
|
||||
titleKey: 'widgetLabCasesPendingSend',
|
||||
icon: FlaskConical,
|
||||
color: 'red',
|
||||
orgTypes: ['CLINIC'],
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'labCasesPendingSend');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'casesReceivedToday',
|
||||
titleKey: 'widgetCasesReceivedToday',
|
||||
icon: FlaskConical,
|
||||
color: 'blue',
|
||||
orgTypes: ['LAB'],
|
||||
isVisible: (org) => canViewCases(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'casesReceivedToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tasksInProgress',
|
||||
titleKey: 'widgetTasksInProgress',
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'tasksInProgress');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'importantTasks',
|
||||
titleKey: 'widgetImportantTasks',
|
||||
icon: AlertCircle,
|
||||
color: 'red',
|
||||
orgTypes: ['LAB'],
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'importantTasks');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingConnections',
|
||||
titleKey: 'widgetPendingConnections',
|
||||
icon: Link2,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
isVisible: (org) => canManageOrganizations(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingConnections');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seats',
|
||||
titleKey: 'widgetSeats',
|
||||
icon: UserCog,
|
||||
color: 'default',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
if (seats.unlimited) return String(seats.used);
|
||||
return `${seats.used}/${seats.limit ?? 0}`;
|
||||
},
|
||||
formatSubtitle: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
return seats.unlimited ? 'unlimited' : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingStaffInvites',
|
||||
titleKey: 'widgetPendingStaffInvites',
|
||||
icon: UserCog,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
isVisible: (org) => canEditStaff(org) || Boolean(org?.isOwner),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingStaffInvites');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getEligibleTodayKpis(org: Organization | null): TodayKpiDefinition[] {
|
||||
if (!org) return [];
|
||||
|
||||
return TODAY_KPI_DEFINITIONS.filter((definition) => {
|
||||
if (!definition.orgTypes.includes(org.type)) return false;
|
||||
return definition.isVisible(org);
|
||||
});
|
||||
}
|
||||
|
||||
export function getVisibleTodayKpis(
|
||||
org: Organization | null,
|
||||
widgets: TodaySummaryWidgets,
|
||||
): TodayKpiDefinition[] {
|
||||
return getEligibleTodayKpis(org).filter(
|
||||
(definition) => definition.formatValue(widgets) !== null,
|
||||
);
|
||||
}
|
||||
14
frontend/src/lib/api/today.ts
Normal file
14
frontend/src/lib/api/today.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { apiClient } from './client';
|
||||
import type { TodaySummaryResponse } from '@/types/today';
|
||||
|
||||
export interface TodaySummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const todayApi = {
|
||||
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
|
||||
const response = await apiClient.get('/today/summary', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
49
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
49
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
|
||||
import { todayApi } from '@/lib/api/today';
|
||||
import type { TodaySummaryData } from '@/types/today';
|
||||
import type { ApiError } from '@/types/api';
|
||||
|
||||
interface UseTodaySummaryResult {
|
||||
data: TodaySummaryData | null;
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTodaySummary(enabled = true): UseTodaySummaryResult {
|
||||
const [data, setData] = useState<TodaySummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const range = getLocalDayIsoRange(new Date());
|
||||
const response = await todayApi.summary(range);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setData(null);
|
||||
setError(err as ApiError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
return { data, loading, error, reload };
|
||||
}
|
||||
32
frontend/src/types/today.ts
Normal file
32
frontend/src/types/today.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type TodayWidgetKey =
|
||||
| 'appointmentsToday'
|
||||
| 'patientsToday'
|
||||
| 'treatmentsToday'
|
||||
| 'draftTreatments'
|
||||
| 'labCasesPendingSend'
|
||||
| 'casesReceivedToday'
|
||||
| 'tasksInProgress'
|
||||
| 'importantTasks'
|
||||
| 'pendingConnections'
|
||||
| 'seats'
|
||||
| 'pendingStaffInvites';
|
||||
|
||||
export type TodaySummaryWidgets = Partial<
|
||||
Record<
|
||||
TodayWidgetKey,
|
||||
| { count: number }
|
||||
| { used: number; limit: number | null; unlimited: boolean }
|
||||
>
|
||||
>;
|
||||
|
||||
export interface TodaySummaryData {
|
||||
generatedAt: string;
|
||||
orgType: 'CLINIC' | 'LAB';
|
||||
range: { from: string; to: string };
|
||||
widgets: TodaySummaryWidgets;
|
||||
}
|
||||
|
||||
export interface TodaySummaryResponse {
|
||||
success: boolean;
|
||||
data: TodaySummaryData;
|
||||
}
|
||||
Reference in New Issue
Block a user