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 { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||||
|
import { TodayModule } from './modules/today/today.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
|
|||||||
LabCaseCommentsModule,
|
LabCaseCommentsModule,
|
||||||
StaffModule,
|
StaffModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
|
TodayModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
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.",
|
"noSubscriptionNotice": "This organization does not have an active subscription yet.",
|
||||||
"choosePlanLink": "Choose a plan",
|
"choosePlanLink": "Choose a plan",
|
||||||
"noSubscriptionCta": "to start the purchase process.",
|
"noSubscriptionCta": "to start the purchase process.",
|
||||||
"cardTodaysAppointments": "Today's Appointments",
|
"noWidgets": "No dashboard metrics are available for your current permissions.",
|
||||||
"cardActivePatients": "Active Patients",
|
"loadError": "Could not load dashboard metrics.",
|
||||||
"cardNewLabCase": "New Lab Case",
|
"seatsUnlimited": "Unlimited plan",
|
||||||
"cardTodayInvoices": "Today invoices"
|
"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": {
|
"staff": {
|
||||||
"redirecting": "Redirecting…",
|
"redirecting": "Redirecting…",
|
||||||
|
|||||||
@@ -194,10 +194,20 @@
|
|||||||
"noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.",
|
"noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.",
|
||||||
"choosePlanLink": "انتخاب طرح",
|
"choosePlanLink": "انتخاب طرح",
|
||||||
"noSubscriptionCta": "برای شروع فرآیند خرید.",
|
"noSubscriptionCta": "برای شروع فرآیند خرید.",
|
||||||
"cardTodaysAppointments": "نوبتهای امروز",
|
"noWidgets": "هیچ معیاری برای دسترسی فعلی شما در دسترس نیست.",
|
||||||
"cardActivePatients": "بیماران فعال",
|
"loadError": "بارگذاری معیارهای داشبورد ناموفق بود.",
|
||||||
"cardNewLabCase": "پرونده جدید لابراتوار",
|
"seatsUnlimited": "طرح نامحدود",
|
||||||
"cardTodayInvoices": "صورتحسابهای امروز"
|
"widgetAppointmentsToday": "نوبتهای امروز",
|
||||||
|
"widgetPatientsToday": "بیماران امروز",
|
||||||
|
"widgetTreatmentsToday": "درمانهای امروز",
|
||||||
|
"widgetDraftTreatments": "درمانهای پیشنویس",
|
||||||
|
"widgetLabCasesPendingSend": "پروندههای در انتظار ارسال",
|
||||||
|
"widgetCasesReceivedToday": "پروندههای دریافتی امروز",
|
||||||
|
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||||
|
"widgetImportantTasks": "وظایف مهم",
|
||||||
|
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||||
|
"widgetSeats": "استفاده از صندلی",
|
||||||
|
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار"
|
||||||
},
|
},
|
||||||
"staff": {
|
"staff": {
|
||||||
"redirecting": "در حال انتقال...",
|
"redirecting": "در حال انتقال...",
|
||||||
|
|||||||
@@ -194,10 +194,20 @@
|
|||||||
"noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.",
|
"noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.",
|
||||||
"choosePlanLink": "Kies een abonnement",
|
"choosePlanLink": "Kies een abonnement",
|
||||||
"noSubscriptionCta": "om het aankoopproces te starten.",
|
"noSubscriptionCta": "om het aankoopproces te starten.",
|
||||||
"cardTodaysAppointments": "Afspraken van vandaag",
|
"noWidgets": "Geen dashboardstatistieken beschikbaar voor uw huidige rechten.",
|
||||||
"cardActivePatients": "Actieve patiënten",
|
"loadError": "Dashboardstatistieken konden niet worden geladen.",
|
||||||
"cardNewLabCase": "Nieuwe laboratoriumcase",
|
"seatsUnlimited": "Onbeperkt abonnement",
|
||||||
"cardTodayInvoices": "Facturen van vandaag"
|
"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": {
|
"staff": {
|
||||||
"redirecting": "Bezig met doorsturen...",
|
"redirecting": "Bezig met doorsturen...",
|
||||||
|
|||||||
@@ -186,10 +186,20 @@ const en = {
|
|||||||
noSubscriptionNotice: 'This organization does not have an active subscription yet.',
|
noSubscriptionNotice: 'This organization does not have an active subscription yet.',
|
||||||
choosePlanLink: 'Choose a plan',
|
choosePlanLink: 'Choose a plan',
|
||||||
noSubscriptionCta: 'to start the purchase process.',
|
noSubscriptionCta: 'to start the purchase process.',
|
||||||
cardTodaysAppointments: "Today's Appointments",
|
noWidgets: 'No dashboard metrics are available for your current permissions.',
|
||||||
cardActivePatients: 'Active Patients',
|
loadError: 'Could not load dashboard metrics.',
|
||||||
cardNewLabCase: 'New Lab Case',
|
seatsUnlimited: 'Unlimited plan',
|
||||||
cardTodayInvoices: 'Today invoices',
|
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: {
|
staff: {
|
||||||
redirecting: 'Redirecting…',
|
redirecting: 'Redirecting…',
|
||||||
|
|||||||
@@ -3,22 +3,23 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
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() {
|
export default function TodayPage() {
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
|
const { data, loading, error } = useTodaySummary(Boolean(currentOrganization?.id));
|
||||||
const showNoSubscriptionNotice =
|
const showNoSubscriptionNotice =
|
||||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold mb-6">
|
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
|
||||||
{t('welcomeBack')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{showNoSubscriptionNotice && (
|
{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">
|
<p className="text-sm text-amber-200">
|
||||||
{t('noSubscriptionNotice')}{' '}
|
{t('noSubscriptionNotice')}{' '}
|
||||||
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
|
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
|
||||||
@@ -29,27 +30,15 @@ export default function TodayPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
{error ? (
|
||||||
<Card>
|
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4">
|
||||||
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
|
<p className="text-sm text-badge-danger-fg">
|
||||||
<p className="text-2xl font-semibold mt-2">12</p>
|
{formatApiErrorMessage(error, t('loadError'))}
|
||||||
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TodayKpiGrid widgets={data?.widgets ?? {}} loading={loading} />
|
||||||
</div>
|
</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