Shitty gadgets removed. Some useful gadgets added.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsISO8601, IsOptional } from 'class-validator';
|
import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class TodaySummaryQueryDto {
|
export class TodaySummaryQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
@@ -17,4 +18,16 @@ export class TodaySummaryQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Client UTC offset in minutes (same sign as Date.getTimezoneOffset negated). Used for hourly buckets.',
|
||||||
|
example: 210,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(-840)
|
||||||
|
@Max(840)
|
||||||
|
utcOffsetMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StaffModule } from '../staff/staff.module';
|
||||||
import { TodayController } from './today.controller';
|
import { TodayController } from './today.controller';
|
||||||
import { TodayService } from './today.service';
|
import { TodayService } from './today.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [StaffModule],
|
||||||
controllers: [TodayController],
|
controllers: [TodayController],
|
||||||
providers: [TodayService],
|
providers: [TodayService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,13 +18,33 @@ import {
|
|||||||
normalizeCatalogLocale,
|
normalizeCatalogLocale,
|
||||||
type CatalogLocale,
|
type CatalogLocale,
|
||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
|
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
|
||||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||||
|
|
||||||
type ChartBucket = { code: string; label: string; count: number };
|
type ChartBucket = { code: string; label: string; count: number };
|
||||||
|
|
||||||
|
type StackedDayBucket = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
completed: number;
|
||||||
|
received: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaseCompletionChart = {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
type TodayCharts = {
|
type TodayCharts = {
|
||||||
treatmentMixWeek?: ChartBucket[];
|
treatmentMixWeek?: ChartBucket[];
|
||||||
tasksByWorkflowStep?: ChartBucket[];
|
tasksByWorkflowStep?: ChartBucket[];
|
||||||
|
appointmentsByProvider?: ChartBucket[];
|
||||||
|
caseCompletion?: CaseCompletionChart;
|
||||||
|
appointmentsWeekAll?: ChartBucket[];
|
||||||
|
appointmentsWeekMine?: ChartBucket[];
|
||||||
|
labTaskActivityWeek?: StackedDayBucket[];
|
||||||
|
inProgressTasksByProsthesis?: ChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TodayActions = {
|
type TodayActions = {
|
||||||
@@ -44,11 +64,13 @@ type TodayWidgets = {
|
|||||||
draftTreatments?: { count: number };
|
draftTreatments?: { count: number };
|
||||||
labCasesPendingSend?: { count: number };
|
labCasesPendingSend?: { count: number };
|
||||||
casesReceivedToday?: { count: number };
|
casesReceivedToday?: { count: number };
|
||||||
|
casesInProgress?: { count: number };
|
||||||
tasksInProgress?: { count: number };
|
tasksInProgress?: { count: number };
|
||||||
importantTasks?: { count: number };
|
importantTasks?: { count: number };
|
||||||
pendingConnections?: { count: number };
|
pendingConnections?: { count: number };
|
||||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
seats?: { used: number; limit: number | null; unlimited: boolean };
|
||||||
pendingStaffInvites?: { count: number };
|
pendingStaffInvites?: { count: number };
|
||||||
|
providersWithoutWorkingHours?: { count: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,6 +78,7 @@ export class TodayService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly catalogLabels: CatalogLabelService,
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
|
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
@@ -90,16 +113,15 @@ export class TodayService {
|
|||||||
this.loadAppointmentsToday(organizationId, from, to, widgets),
|
this.loadAppointmentsToday(organizationId, from, to, widgets),
|
||||||
);
|
);
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadUpcomingAppointmentsToday(organizationId, from, to, actions),
|
this.loadAppointmentsByProvider(organizationId, from, to, charts),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.canViewPatients(membership.isOwner, permissionNames) ||
|
|
||||||
this.canViewAppointments(membership.isOwner, permissionNames)
|
|
||||||
) {
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadPatientsToday(organizationId, from, to, widgets),
|
this.loadAppointmentsWeekAll(
|
||||||
|
organizationId,
|
||||||
|
to,
|
||||||
|
query.utcOffsetMinutes,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +134,53 @@ export class TodayService {
|
|||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
||||||
);
|
);
|
||||||
|
tasks.push(
|
||||||
|
this.loadAppointmentsWeekMine(
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
to,
|
||||||
|
query.utcOffsetMinutes,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tasks.push(
|
||||||
|
this.loadUpcomingAppointmentsToday(
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
actions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.canViewPatients(membership.isOwner, permissionNames) ||
|
||||||
|
this.canViewAppointments(membership.isOwner, permissionNames)
|
||||||
|
) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadPatientsToday(organizationId, from, to, widgets),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadProvidersWithoutWorkingHours(organizationId, widgets),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgType === 'LAB') {
|
if (orgType === 'LAB') {
|
||||||
|
const canViewLabWork =
|
||||||
|
this.canViewCases(membership.isOwner, permissionNames) ||
|
||||||
|
this.canViewTasks(membership.isOwner, permissionNames);
|
||||||
|
|
||||||
if (this.canViewCases(membership.isOwner, permissionNames)) {
|
if (this.canViewCases(membership.isOwner, permissionNames)) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadCasesReceivedToday(organizationId, from, to, widgets),
|
this.loadCasesReceivedToday(organizationId, from, to, widgets),
|
||||||
);
|
);
|
||||||
|
tasks.push(this.loadCasesInProgress(organizationId, widgets));
|
||||||
|
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||||
@@ -127,6 +188,20 @@ export class TodayService {
|
|||||||
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
||||||
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
|
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canViewLabWork) {
|
||||||
|
tasks.push(
|
||||||
|
this.loadLabTaskActivityWeek(
|
||||||
|
organizationId,
|
||||||
|
to,
|
||||||
|
query.utcOffsetMinutes,
|
||||||
|
charts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tasks.push(
|
||||||
|
this.loadInProgressTasksByProsthesis(organizationId, locale, charts),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
|
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
|
||||||
@@ -264,6 +339,7 @@ export class TodayService {
|
|||||||
|
|
||||||
private async loadUpcomingAppointmentsToday(
|
private async loadUpcomingAppointmentsToday(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
providerUserId: string,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
actions: TodayActions,
|
actions: TodayActions,
|
||||||
@@ -272,6 +348,7 @@ export class TodayService {
|
|||||||
const items = await this.prisma.appointment.findMany({
|
const items = await this.prisma.appointment.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
providerUserId,
|
||||||
startAt: { lt: to },
|
startAt: { lt: to },
|
||||||
endAt: { gt: now > from ? now : from },
|
endAt: { gt: now > from ? now : from },
|
||||||
},
|
},
|
||||||
@@ -279,7 +356,7 @@ export class TodayService {
|
|||||||
patient: { select: { firstName: true, lastName: true } },
|
patient: { select: { firstName: true, lastName: true } },
|
||||||
},
|
},
|
||||||
orderBy: { startAt: 'asc' },
|
orderBy: { startAt: 'asc' },
|
||||||
take: 5,
|
take: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
||||||
@@ -448,6 +525,303 @@ export class TodayService {
|
|||||||
widgets.pendingStaffInvites = { count };
|
widgets.pendingStaffInvites = { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadAppointmentsByProvider(
|
||||||
|
organizationId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const appointments = await this.prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
startAt: { lt: to },
|
||||||
|
endAt: { gt: from },
|
||||||
|
},
|
||||||
|
select: { providerUserId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsByProvider = new Map<string, number>();
|
||||||
|
for (const appointment of appointments) {
|
||||||
|
countsByProvider.set(
|
||||||
|
appointment.providerUserId,
|
||||||
|
(countsByProvider.get(appointment.providerUserId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countsByProvider.size === 0) {
|
||||||
|
charts.appointmentsByProvider = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...countsByProvider.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: sorted.map(([userId]) => userId) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
|
||||||
|
charts.appointmentsByProvider = sorted.map(([userId, count]) => ({
|
||||||
|
code: userId,
|
||||||
|
label: nameById.get(userId) ?? userId,
|
||||||
|
count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadProvidersWithoutWorkingHours(
|
||||||
|
organizationId: string,
|
||||||
|
widgets: TodayWidgets,
|
||||||
|
) {
|
||||||
|
const members = await this.prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
isOwner: false,
|
||||||
|
isActive: true,
|
||||||
|
permissions: {
|
||||||
|
some: {
|
||||||
|
permission: { name: 'TAB_TREATMENT_EDIT' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
widgets.providersWithoutWorkingHours = { count: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleBlocksByMembership =
|
||||||
|
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds(
|
||||||
|
members.map((member) => member.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = members.filter((member) => {
|
||||||
|
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
|
||||||
|
return blocks.length === 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
widgets.providersWithoutWorkingHours = { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||||
|
const cases = await this.prisma.labCase.findMany({
|
||||||
|
where: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tasks: { select: { status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = cases.filter((labCase) => {
|
||||||
|
if (labCase.tasks.length === 0) return false;
|
||||||
|
const completed = labCase.tasks.filter(
|
||||||
|
(task) => task.status === LabTaskStatus.COMPLETED,
|
||||||
|
).length;
|
||||||
|
return completed < labCase.tasks.length;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
widgets.casesInProgress = { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCaseCompletion(labOrganizationId: string, charts: TodayCharts) {
|
||||||
|
const tasks = await this.prisma.labCaseTask.findMany({
|
||||||
|
where: {
|
||||||
|
labCase: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = tasks.length;
|
||||||
|
const completed = tasks.filter(
|
||||||
|
(task) => task.status === LabTaskStatus.COMPLETED,
|
||||||
|
).length;
|
||||||
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
charts.caseCompletion = { completed, total, percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAppointmentsWeekAll(
|
||||||
|
organizationId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
utcOffsetMinutes: number | undefined,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries(
|
||||||
|
organizationId,
|
||||||
|
rangeEnd,
|
||||||
|
utcOffsetMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAppointmentsWeekMine(
|
||||||
|
organizationId: string,
|
||||||
|
providerUserId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
utcOffsetMinutes: number | undefined,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries(
|
||||||
|
organizationId,
|
||||||
|
rangeEnd,
|
||||||
|
utcOffsetMinutes,
|
||||||
|
providerUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAppointmentsWeekSeries(
|
||||||
|
organizationId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
utcOffsetMinutes: number | undefined,
|
||||||
|
providerUserId?: string,
|
||||||
|
): Promise<ChartBucket[]> {
|
||||||
|
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||||
|
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||||
|
const weekEnd = rangeEnd;
|
||||||
|
|
||||||
|
const appointments = await this.prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
startAt: { gte: weekStart, lt: weekEnd },
|
||||||
|
...(providerUserId ? { providerUserId } : {}),
|
||||||
|
},
|
||||||
|
select: { startAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countsByDay = new Map<string, number>();
|
||||||
|
for (const bucket of dayBuckets) {
|
||||||
|
countsByDay.set(bucket.code, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||||
|
for (const appointment of appointments) {
|
||||||
|
const dayKey = localDayKeyFromDate(appointment.startAt, offsetMs);
|
||||||
|
if (countsByDay.has(dayKey)) {
|
||||||
|
countsByDay.set(dayKey, (countsByDay.get(dayKey) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayBuckets.map((bucket) => ({
|
||||||
|
code: bucket.code,
|
||||||
|
label: bucket.label,
|
||||||
|
count: countsByDay.get(bucket.code) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLabTaskActivityWeek(
|
||||||
|
labOrganizationId: string,
|
||||||
|
rangeEnd: Date,
|
||||||
|
utcOffsetMinutes: number | undefined,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||||
|
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||||
|
const weekEnd = rangeEnd;
|
||||||
|
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||||
|
|
||||||
|
const completedCounts = new Map<string, number>();
|
||||||
|
const receivedCounts = new Map<string, number>();
|
||||||
|
for (const bucket of dayBuckets) {
|
||||||
|
completedCounts.set(bucket.code, 0);
|
||||||
|
receivedCounts.set(bucket.code, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTasks = await this.prisma.labCaseTask.findMany({
|
||||||
|
where: {
|
||||||
|
status: LabTaskStatus.COMPLETED,
|
||||||
|
lastStatusChangedAt: { gte: weekStart, lt: weekEnd },
|
||||||
|
labCase: {
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { lastStatusChangedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of completedTasks) {
|
||||||
|
if (!task.lastStatusChangedAt) continue;
|
||||||
|
const dayKey = localDayKeyFromDate(task.lastStatusChangedAt, offsetMs);
|
||||||
|
if (completedCounts.has(dayKey)) {
|
||||||
|
completedCounts.set(dayKey, (completedCounts.get(dayKey) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sends = await this.prisma.labCaseSend.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: labOrganizationId,
|
||||||
|
sentAt: { gte: weekStart, lt: weekEnd },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
labCase: { select: { tasks: { select: { id: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const send of sends) {
|
||||||
|
const dayKey = localDayKeyFromDate(send.sentAt, offsetMs);
|
||||||
|
if (receivedCounts.has(dayKey)) {
|
||||||
|
receivedCounts.set(
|
||||||
|
dayKey,
|
||||||
|
(receivedCounts.get(dayKey) ?? 0) + send.labCase.tasks.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
charts.labTaskActivityWeek = dayBuckets.map((bucket) => ({
|
||||||
|
code: bucket.code,
|
||||||
|
label: bucket.label,
|
||||||
|
completed: completedCounts.get(bucket.code) ?? 0,
|
||||||
|
received: receivedCounts.get(bucket.code) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInProgressTasksByProsthesis(
|
||||||
|
labOrganizationId: string,
|
||||||
|
locale: CatalogLocale,
|
||||||
|
charts: TodayCharts,
|
||||||
|
) {
|
||||||
|
const grouped = await this.prisma.labCaseTask.groupBy({
|
||||||
|
by: ['prosthesisTypeCode'],
|
||||||
|
where: {
|
||||||
|
status: LabTaskStatus.IN_PROGRESS,
|
||||||
|
labCase: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = grouped
|
||||||
|
.map((row) => ({
|
||||||
|
code: row.prosthesisTypeCode,
|
||||||
|
count: aggregateCount(row._count),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
charts.inProgressTasksByProsthesis = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
sorted.map((row) => row.code),
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
charts.inProgressTasksByProsthesis = sorted.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
label: labels.get(row.code) ?? row.code,
|
||||||
|
count: row.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
||||||
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
@@ -567,3 +941,37 @@ function aggregateCount(
|
|||||||
if (!count || count === true) return 0;
|
if (!count || count === true) return 0;
|
||||||
return count._all ?? 0;
|
return count._all ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocalDayBucket = { code: string; label: string; start: Date; end: Date };
|
||||||
|
|
||||||
|
function buildLastSevenLocalDayBuckets(
|
||||||
|
rangeEnd: Date,
|
||||||
|
utcOffsetMinutes?: number,
|
||||||
|
): LocalDayBucket[] {
|
||||||
|
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||||
|
const dayMs = 86_400_000;
|
||||||
|
const buckets: LocalDayBucket[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 7; index += 1) {
|
||||||
|
const start = new Date(rangeEnd.getTime() - (7 - index) * dayMs);
|
||||||
|
const end = new Date(start.getTime() + dayMs);
|
||||||
|
const code = localDayKeyFromDate(start, offsetMs);
|
||||||
|
buckets.push({
|
||||||
|
code,
|
||||||
|
label: code,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDayKeyFromDate(date: Date, offsetMs: number): string {
|
||||||
|
const localMs = date.getTime() + offsetMs;
|
||||||
|
const local = new Date(localMs);
|
||||||
|
const year = local.getUTCFullYear();
|
||||||
|
const month = String(local.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(local.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,13 +203,31 @@
|
|||||||
"widgetDraftTreatments": "Draft Treatments",
|
"widgetDraftTreatments": "Draft Treatments",
|
||||||
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
|
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
|
||||||
"widgetCasesReceivedToday": "Cases Received Today",
|
"widgetCasesReceivedToday": "Cases Received Today",
|
||||||
|
"widgetCasesInProgress": "Cases In Progress",
|
||||||
"widgetTasksInProgress": "Tasks In Progress",
|
"widgetTasksInProgress": "Tasks In Progress",
|
||||||
"widgetImportantTasks": "Important Tasks",
|
"widgetImportantTasks": "Important Tasks",
|
||||||
"widgetPendingConnections": "Pending Connections",
|
"widgetPendingConnections": "Pending Connections",
|
||||||
|
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||||
"widgetSeats": "Seat Usage",
|
"widgetSeats": "Seat Usage",
|
||||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
"widgetPendingStaffInvites": "Pending Staff Invites",
|
||||||
|
"chartAppointmentsWeekAllTitle": "Appointments This Week",
|
||||||
|
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
||||||
|
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
||||||
|
"chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days",
|
||||||
|
"chartLabTaskActivityTitle": "Lab Task Activity",
|
||||||
|
"chartLabTaskActivitySubtitle": "Last 7 days",
|
||||||
|
"chartLabTaskCompletedLegend": "Completed",
|
||||||
|
"chartLabTaskReceivedLegend": "Received",
|
||||||
|
"chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis",
|
||||||
|
"chartProsthesisMixSubtitle": "Current workload mix",
|
||||||
|
"chartAppointmentsByProviderTitle": "Appointments by Provider",
|
||||||
|
"chartAppointmentsByProviderSubtitle": "Today",
|
||||||
"chartTreatmentMixTitle": "Treatment Mix",
|
"chartTreatmentMixTitle": "Treatment Mix",
|
||||||
"chartTreatmentMixSubtitle": "Last 7 days",
|
"chartTreatmentMixSubtitle": "Last 7 days",
|
||||||
|
"chartCaseCompletionTitle": "Case Completion",
|
||||||
|
"chartCaseCompletionSubtitle": "All active cases",
|
||||||
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
|
"chartCaseCompletionTasks": "Tasks completed",
|
||||||
"chartTasksByStepTitle": "Tasks by Workflow Step",
|
"chartTasksByStepTitle": "Tasks by Workflow Step",
|
||||||
"chartTasksByStepSubtitle": "In progress now",
|
"chartTasksByStepSubtitle": "In progress now",
|
||||||
"chartEmpty": "No data for this period yet.",
|
"chartEmpty": "No data for this period yet.",
|
||||||
|
|||||||
@@ -203,13 +203,31 @@
|
|||||||
"widgetDraftTreatments": "درمانهای پیشنویس",
|
"widgetDraftTreatments": "درمانهای پیشنویس",
|
||||||
"widgetLabCasesPendingSend": "پروندههای در انتظار ارسال",
|
"widgetLabCasesPendingSend": "پروندههای در انتظار ارسال",
|
||||||
"widgetCasesReceivedToday": "پروندههای دریافتی امروز",
|
"widgetCasesReceivedToday": "پروندههای دریافتی امروز",
|
||||||
|
"widgetCasesInProgress": "پروندههای در حال انجام",
|
||||||
"widgetTasksInProgress": "وظایف در حال انجام",
|
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||||
"widgetImportantTasks": "وظایف مهم",
|
"widgetImportantTasks": "وظایف مهم",
|
||||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||||
|
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||||
"widgetSeats": "استفاده از صندلی",
|
"widgetSeats": "استفاده از صندلی",
|
||||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||||
|
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
||||||
|
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
||||||
|
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
||||||
|
"chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته",
|
||||||
|
"chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه",
|
||||||
|
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
|
||||||
|
"chartLabTaskCompletedLegend": "تکمیلشده",
|
||||||
|
"chartLabTaskReceivedLegend": "دریافتشده",
|
||||||
|
"chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||||
|
"chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی",
|
||||||
|
"chartAppointmentsByProviderTitle": "نوبتها بر اساس ارائهدهنده",
|
||||||
|
"chartAppointmentsByProviderSubtitle": "امروز",
|
||||||
"chartTreatmentMixTitle": "ترکیب درمانها",
|
"chartTreatmentMixTitle": "ترکیب درمانها",
|
||||||
"chartTreatmentMixSubtitle": "۷ روز گذشته",
|
"chartTreatmentMixSubtitle": "۷ روز گذشته",
|
||||||
|
"chartCaseCompletionTitle": "تکمیل پروندهها",
|
||||||
|
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
||||||
|
"chartCaseCompletionPercent": "{percent}٪",
|
||||||
|
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||||
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
|
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
|
||||||
"chartTasksByStepSubtitle": "در حال انجام",
|
"chartTasksByStepSubtitle": "در حال انجام",
|
||||||
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
||||||
|
|||||||
@@ -203,13 +203,31 @@
|
|||||||
"widgetDraftTreatments": "Conceptbehandelingen",
|
"widgetDraftTreatments": "Conceptbehandelingen",
|
||||||
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
|
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
|
||||||
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
|
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
|
||||||
|
"widgetCasesInProgress": "Cases in uitvoering",
|
||||||
"widgetTasksInProgress": "Taken in uitvoering",
|
"widgetTasksInProgress": "Taken in uitvoering",
|
||||||
"widgetImportantTasks": "Belangrijke taken",
|
"widgetImportantTasks": "Belangrijke taken",
|
||||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||||
|
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||||
"widgetSeats": "Zitplaatsgebruik",
|
"widgetSeats": "Zitplaatsgebruik",
|
||||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
||||||
|
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
|
||||||
|
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
||||||
|
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
||||||
|
"chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen",
|
||||||
|
"chartLabTaskActivityTitle": "Labtaakactiviteit",
|
||||||
|
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
|
||||||
|
"chartLabTaskCompletedLegend": "Voltooid",
|
||||||
|
"chartLabTaskReceivedLegend": "Ontvangen",
|
||||||
|
"chartProsthesisMixTitle": "Lopende taken per prothese",
|
||||||
|
"chartProsthesisMixSubtitle": "Huidige werklastmix",
|
||||||
|
"chartAppointmentsByProviderTitle": "Afspraken per behandelaar",
|
||||||
|
"chartAppointmentsByProviderSubtitle": "Vandaag",
|
||||||
"chartTreatmentMixTitle": "Behandelingsmix",
|
"chartTreatmentMixTitle": "Behandelingsmix",
|
||||||
"chartTreatmentMixSubtitle": "Afgelopen 7 dagen",
|
"chartTreatmentMixSubtitle": "Afgelopen 7 dagen",
|
||||||
|
"chartCaseCompletionTitle": "Casevoltooiing",
|
||||||
|
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
||||||
|
"chartCaseCompletionPercent": "{percent}%",
|
||||||
|
"chartCaseCompletionTasks": "Taken voltooid",
|
||||||
"chartTasksByStepTitle": "Taken per workflowstap",
|
"chartTasksByStepTitle": "Taken per workflowstap",
|
||||||
"chartTasksByStepSubtitle": "Nu in uitvoering",
|
"chartTasksByStepSubtitle": "Nu in uitvoering",
|
||||||
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Link } from '@/i18n/navigation';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
canAccessAppointmentsSection,
|
||||||
|
canViewAppointmentsTab,
|
||||||
|
canViewCases,
|
||||||
|
canViewLabCasesOrTasks,
|
||||||
canViewTasks,
|
canViewTasks,
|
||||||
canViewTreatment,
|
canViewTreatment,
|
||||||
} from '@/components/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
@@ -27,17 +30,26 @@ export default function TodayPage() {
|
|||||||
const showNoSubscriptionNotice =
|
const showNoSubscriptionNotice =
|
||||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||||
|
|
||||||
const showUpcoming = canAccessAppointmentsSection(currentOrganization);
|
const showUpcoming =
|
||||||
const showCharts =
|
currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization);
|
||||||
(currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization)) ||
|
const showCharts = useMemo(() => {
|
||||||
(currentOrganization?.type === 'LAB' && canViewTasks(currentOrganization));
|
const orgType = currentOrganization?.type;
|
||||||
|
if (!orgType) return false;
|
||||||
|
|
||||||
const actionLayoutClass = useMemo(() => {
|
if (orgType === 'CLINIC') {
|
||||||
if (showUpcoming && showCharts) {
|
return (
|
||||||
return 'grid grid-cols-1 xl:grid-cols-2 gap-4 items-start';
|
canAccessAppointmentsSection(currentOrganization) ||
|
||||||
|
canViewAppointmentsTab(currentOrganization) ||
|
||||||
|
canViewTreatment(currentOrganization)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return 'grid grid-cols-1 gap-4';
|
|
||||||
}, [showUpcoming, showCharts]);
|
return (
|
||||||
|
canViewCases(currentOrganization) ||
|
||||||
|
canViewTasks(currentOrganization) ||
|
||||||
|
canViewLabCasesOrTasks(currentOrganization)
|
||||||
|
);
|
||||||
|
}, [currentOrganization]);
|
||||||
|
|
||||||
const sectionErrorMessage = t('sectionLoadError');
|
const sectionErrorMessage = t('sectionLoadError');
|
||||||
|
|
||||||
@@ -89,33 +101,38 @@ export default function TodayPage() {
|
|||||||
/>
|
/>
|
||||||
</TodayWidgetErrorBoundary>
|
</TodayWidgetErrorBoundary>
|
||||||
|
|
||||||
{(showUpcoming || showCharts) && (!error || data) ? (
|
{showUpcoming && (!error || data) ? (
|
||||||
<div className={actionLayoutClass}>
|
<TodayWidgetErrorBoundary
|
||||||
{showUpcoming ? (
|
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||||
<TodayWidgetErrorBoundary
|
>
|
||||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
<div
|
||||||
>
|
className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||||
<TodayUpcomingAppointments
|
>
|
||||||
actions={data?.actions ?? {}}
|
<TodayUpcomingAppointments
|
||||||
loading={loading}
|
actions={data?.actions ?? {}}
|
||||||
isInitialLoad={isInitialLoad}
|
loading={loading}
|
||||||
/>
|
isInitialLoad={isInitialLoad}
|
||||||
</TodayWidgetErrorBoundary>
|
/>
|
||||||
) : null}
|
{showCharts ? (
|
||||||
|
|
||||||
{showCharts ? (
|
|
||||||
<TodayWidgetErrorBoundary
|
|
||||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
|
||||||
>
|
|
||||||
<TodayChartsSection
|
<TodayChartsSection
|
||||||
charts={data?.charts ?? {}}
|
charts={data?.charts ?? {}}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
isInitialLoad={isInitialLoad}
|
isInitialLoad={isInitialLoad}
|
||||||
className={showUpcoming ? '' : 'max-w-none'}
|
embedded
|
||||||
/>
|
/>
|
||||||
</TodayWidgetErrorBoundary>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</TodayWidgetErrorBoundary>
|
||||||
|
) : showCharts && (!error || data) ? (
|
||||||
|
<TodayWidgetErrorBoundary
|
||||||
|
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||||
|
>
|
||||||
|
<TodayChartsSection
|
||||||
|
charts={data?.charts ?? {}}
|
||||||
|
loading={loading}
|
||||||
|
isInitialLoad={isInitialLoad}
|
||||||
|
/>
|
||||||
|
</TodayWidgetErrorBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
|
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
|
||||||
export default function TreatmentPage() {
|
export default function TreatmentPage() {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialAppointmentId = searchParams.get('appointmentId');
|
||||||
|
|
||||||
if (!isAuthReady || !user) {
|
if (!isAuthReady || !user) {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +18,10 @@ export default function TreatmentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
|
<TreatmentWorkspace
|
||||||
|
userId={user.id}
|
||||||
|
currentOrganization={currentOrganization}
|
||||||
|
initialAppointmentId={initialAppointmentId}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,3 +210,18 @@ export function canEditTasks(org: Organization | null): boolean {
|
|||||||
if (org.isOwner) return true;
|
if (org.isOwner) return true;
|
||||||
return hasPermission(org, 'TAB_TASKS_EDIT');
|
return hasPermission(org, 'TAB_TASKS_EDIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Appointments tab only (excludes treatment-only access). */
|
||||||
|
export function canViewAppointmentsTab(org: Organization | null): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
if (org.type !== 'CLINIC') return false;
|
||||||
|
if (org.isOwner) return true;
|
||||||
|
return (
|
||||||
|
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||||
|
hasPermission(org, 'TAB_APPOINTMENTS_EDIT')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewLabCasesOrTasks(org: Organization | null): boolean {
|
||||||
|
return canViewCases(org) || canViewTasks(org);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTim
|
|||||||
import type { TreatmentAppointment } from '@/types/treatment';
|
import type { TreatmentAppointment } from '@/types/treatment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
|
* For the selected calendar day: if it is today, pick the in-progress appointment,
|
||||||
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
|
* otherwise the appointment whose start time is nearest to now; on other days pick
|
||||||
|
* the first appointment of that day. Returns null when there are no appointments.
|
||||||
*/
|
*/
|
||||||
export function pickAutoAppointment(
|
export function pickAutoAppointment(
|
||||||
appointments: TreatmentAppointment[],
|
appointments: TreatmentAppointment[],
|
||||||
@@ -19,7 +20,23 @@ export function pickAutoAppointment(
|
|||||||
const e = new Date(a.endAt).getTime();
|
const e = new Date(a.endAt).getTime();
|
||||||
if (t >= s && t <= e) return a.id;
|
if (t >= s && t <= e) return a.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nearest = appointments[0];
|
||||||
|
let nearestDistance = Math.abs(new Date(nearest.startAt).getTime() - t);
|
||||||
|
for (const appointment of appointments.slice(1)) {
|
||||||
|
const distance = Math.abs(new Date(appointment.startAt).getTime() - t);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = appointment;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return appointments[0].id;
|
return appointments[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function treatmentAppointmentHref(appointmentId?: string): string {
|
||||||
|
if (!appointmentId) return '/treatment';
|
||||||
|
return `/treatment?appointmentId=${encodeURIComponent(appointmentId)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ChartCard({
|
|||||||
<p className="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
|
<p className="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-[220px]">{children}</div>
|
<div className="flex flex-1 min-h-[220px] flex-col">{children}</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ export function KpiCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm font-medium">{title}</p>
|
<p className="text-sm font-medium">{title}</p>
|
||||||
{Icon ? <Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden /> : null}
|
{Icon ? (
|
||||||
|
<Icon
|
||||||
|
className="h-4 w-4 shrink-0 !text-current"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
|
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
|
||||||
|
|||||||
66
frontend/src/components/today/TodayAreaChart.tsx
Normal file
66
frontend/src/components/today/TodayAreaChart.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_AXIS_COLOR,
|
||||||
|
TODAY_CHART_GRID_COLOR,
|
||||||
|
TODAY_CHART_PRIMARY_COLOR,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
interface TodayAreaChartProps {
|
||||||
|
data: TodayChartBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayAreaChart({ data }: TodayAreaChartProps) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="todayAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.45} />
|
||||||
|
<stop offset="100%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={1}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(label) => String(label)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke={TODAY_CHART_PRIMARY_COLOR}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#todayAreaFill)"
|
||||||
|
dot={{ r: 3, fill: TODAY_CHART_PRIMARY_COLOR, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5, fill: TODAY_CHART_PRIMARY_COLOR }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,10 @@ import {
|
|||||||
|
|
||||||
interface TodayBarChartProps {
|
interface TodayBarChartProps {
|
||||||
data: TodayChartBucket[];
|
data: TodayChartBucket[];
|
||||||
|
colorForCode?: (code: string, index: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayBarChart({ data }: TodayBarChartProps) {
|
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||||
const chartData = data.map((item) => ({
|
const chartData = data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
shortLabel: truncateLabel(item.label),
|
shortLabel: truncateLabel(item.label),
|
||||||
@@ -38,7 +39,35 @@ export function TodayBarChart({ data }: TodayBarChartProps) {
|
|||||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="shortLabel"
|
dataKey="shortLabel"
|
||||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
tick={
|
||||||
|
colorForCode
|
||||||
|
? (props) => {
|
||||||
|
const { x, y, payload } = props as {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
payload: { value: string };
|
||||||
|
};
|
||||||
|
const index = chartData.findIndex((row) => row.shortLabel === payload.value);
|
||||||
|
const entry = chartData[index];
|
||||||
|
const fill =
|
||||||
|
entry != null
|
||||||
|
? colorForCode(entry.code, index >= 0 ? index : 0)
|
||||||
|
: TODAY_CHART_AXIS_COLOR;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
dy={16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={fill}
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{payload.value}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
|
||||||
|
}
|
||||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
interval={0}
|
interval={0}
|
||||||
@@ -68,7 +97,10 @@ export function TodayBarChart({ data }: TodayBarChartProps) {
|
|||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.code}
|
key={entry.code}
|
||||||
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
|
fill={
|
||||||
|
colorForCode?.(entry.code, index) ??
|
||||||
|
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
|
||||||
canViewTasks,
|
|
||||||
canViewTreatment,
|
|
||||||
} from '@/components/shared/permissions';
|
|
||||||
import { ChartCard } from '@/components/today/ChartCard';
|
import { ChartCard } from '@/components/today/ChartCard';
|
||||||
|
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||||
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||||
|
import {
|
||||||
|
formatTodayChartDayLabel,
|
||||||
|
mapWeekChartBuckets,
|
||||||
|
useTodayDayLabelFormatter,
|
||||||
|
} from '@/components/today/chart-day-labels';
|
||||||
|
import { TodayDonutChart } from '@/components/today/TodayDonutChart';
|
||||||
|
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||||
|
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||||
|
import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart';
|
||||||
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||||
|
import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||||
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import type { TodaySummaryCharts } from '@/types/today';
|
import type { TodaySummaryCharts } from '@/types/today';
|
||||||
|
|
||||||
interface TodayChartsSectionProps {
|
interface TodayChartsSectionProps {
|
||||||
@@ -16,6 +25,8 @@ interface TodayChartsSectionProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isInitialLoad?: boolean;
|
isInitialLoad?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** When true, chart cards render as siblings (no outer grid wrapper). */
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayChartsSection({
|
export function TodayChartsSection({
|
||||||
@@ -23,38 +34,155 @@ export function TodayChartsSection({
|
|||||||
loading = false,
|
loading = false,
|
||||||
isInitialLoad = false,
|
isInitialLoad = false,
|
||||||
className = '',
|
className = '',
|
||||||
|
embedded = false,
|
||||||
}: TodayChartsSectionProps) {
|
}: TodayChartsSectionProps) {
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
|
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const orgType = currentOrganization?.type;
|
const orgType = currentOrganization?.type;
|
||||||
|
|
||||||
|
const showAppointmentsByProvider =
|
||||||
|
orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined;
|
||||||
|
const showAppointmentsWeekAll =
|
||||||
|
orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined;
|
||||||
|
const showAppointmentsWeekMine =
|
||||||
|
orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined;
|
||||||
const showTreatmentMix =
|
const showTreatmentMix =
|
||||||
orgType === 'CLINIC' && canViewTreatment(currentOrganization);
|
orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined;
|
||||||
|
const showCaseCompletion =
|
||||||
|
orgType === 'LAB' && charts.caseCompletion !== undefined;
|
||||||
const showTasksByStep =
|
const showTasksByStep =
|
||||||
orgType === 'LAB' && canViewTasks(currentOrganization);
|
orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined;
|
||||||
|
const showLabTaskActivityWeek =
|
||||||
|
orgType === 'LAB' && charts.labTaskActivityWeek !== undefined;
|
||||||
|
const showInProgressTasksByProsthesis =
|
||||||
|
orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined;
|
||||||
|
|
||||||
if (!showTreatmentMix && !showTasksByStep) {
|
const visibleChartCount =
|
||||||
|
Number(showAppointmentsByProvider) +
|
||||||
|
Number(showAppointmentsWeekAll) +
|
||||||
|
Number(showAppointmentsWeekMine) +
|
||||||
|
Number(showTreatmentMix) +
|
||||||
|
Number(showCaseCompletion) +
|
||||||
|
Number(showTasksByStep) +
|
||||||
|
Number(showLabTaskActivityWeek) +
|
||||||
|
Number(showInProgressTasksByProsthesis);
|
||||||
|
|
||||||
|
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||||
|
const appointmentsWeekAllData = useMemo(
|
||||||
|
() => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter),
|
||||||
|
[charts.appointmentsWeekAll, dayLabelFormatter],
|
||||||
|
);
|
||||||
|
const appointmentsWeekMineData = useMemo(
|
||||||
|
() => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter),
|
||||||
|
[charts.appointmentsWeekMine, dayLabelFormatter],
|
||||||
|
);
|
||||||
|
const treatmentData = charts.treatmentMixWeek ?? [];
|
||||||
|
const tasksData = charts.tasksByWorkflowStep ?? [];
|
||||||
|
const labTaskActivityData = useMemo(
|
||||||
|
() => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter),
|
||||||
|
[charts.labTaskActivityWeek, dayLabelFormatter],
|
||||||
|
);
|
||||||
|
const prosthesisData = charts.inProgressTasksByProsthesis ?? [];
|
||||||
|
const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 };
|
||||||
|
|
||||||
|
const formatDayLabel = (code: string) =>
|
||||||
|
formatTodayChartDayLabel(code, dayLabelFormatter);
|
||||||
|
|
||||||
|
if (visibleChartCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treatmentData = charts.treatmentMixWeek ?? [];
|
|
||||||
const tasksData = charts.tasksByWorkflowStep ?? [];
|
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
|
const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => (
|
||||||
|
<ChartCardSkeleton key={index} />
|
||||||
|
));
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <>{skeletons}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-1 gap-4 ${className}`}>
|
<div className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${className}`}>{skeletons}</div>
|
||||||
{showTreatmentMix ? <ChartCardSkeleton /> : null}
|
|
||||||
{showTasksByStep ? <ChartCardSkeleton /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartCount = (showTreatmentMix ? 1 : 0) + (showTasksByStep ? 1 : 0);
|
const gridClass =
|
||||||
|
visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1';
|
||||||
|
|
||||||
|
const chartCards = (
|
||||||
|
<>
|
||||||
|
{showAppointmentsWeekAll ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsWeekAllTitle')}
|
||||||
|
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||||
|
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showAppointmentsWeekMine ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsWeekMineTitle')}
|
||||||
|
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||||
|
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayAreaChart data={appointmentsWeekMineData} />
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showLabTaskActivityWeek ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartLabTaskActivityTitle')}
|
||||||
|
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||||
|
isEmpty={labTaskActivityData.every(
|
||||||
|
(row) => row.completed === 0 && row.received === 0,
|
||||||
|
)}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayStackedBarChart
|
||||||
|
data={labTaskActivityData}
|
||||||
|
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||||
|
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||||
|
formatDayLabel={formatDayLabel}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showInProgressTasksByProsthesis ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartProsthesisMixTitle')}
|
||||||
|
subtitle={t('chartProsthesisMixSubtitle')}
|
||||||
|
isEmpty={prosthesisData.length === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayDonutChart
|
||||||
|
data={prosthesisData}
|
||||||
|
labelForCode={(code) =>
|
||||||
|
prosthesisData.find((row) => row.code === code)?.label ?? code
|
||||||
|
}
|
||||||
|
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||||
|
swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)}
|
||||||
|
variant="pie"
|
||||||
|
sideLegend
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showAppointmentsByProvider ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartAppointmentsByProviderTitle')}
|
||||||
|
subtitle={t('chartAppointmentsByProviderSubtitle')}
|
||||||
|
isEmpty={appointmentsByProviderData.length === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayHorizontalBarChart data={appointmentsByProviderData} />
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`grid grid-cols-1 ${chartCount > 1 ? 'lg:grid-cols-2' : ''} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
|
|
||||||
>
|
|
||||||
{showTreatmentMix ? (
|
{showTreatmentMix ? (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('chartTreatmentMixTitle')}
|
title={t('chartTreatmentMixTitle')}
|
||||||
@@ -62,7 +190,29 @@ export function TodayChartsSection({
|
|||||||
isEmpty={treatmentData.length === 0}
|
isEmpty={treatmentData.length === 0}
|
||||||
emptyMessage={t('chartEmpty')}
|
emptyMessage={t('chartEmpty')}
|
||||||
>
|
>
|
||||||
<TodayBarChart data={treatmentData} />
|
<TodayBarChart
|
||||||
|
data={treatmentData}
|
||||||
|
colorForCode={(code, index) => treatmentTypeColor(code, index)}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCaseCompletion ? (
|
||||||
|
<ChartCard
|
||||||
|
title={t('chartCaseCompletionTitle')}
|
||||||
|
subtitle={t('chartCaseCompletionSubtitle')}
|
||||||
|
isEmpty={caseCompletion.total === 0}
|
||||||
|
emptyMessage={t('chartEmpty')}
|
||||||
|
>
|
||||||
|
<TodayRadialGaugeChart
|
||||||
|
percent={caseCompletion.percent}
|
||||||
|
completed={caseCompletion.completed}
|
||||||
|
total={caseCompletion.total}
|
||||||
|
percentLabel={t('chartCaseCompletionPercent', {
|
||||||
|
percent: caseCompletion.percent,
|
||||||
|
})}
|
||||||
|
tasksLabel={t('chartCaseCompletionTasks')}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -76,6 +226,18 @@ export function TodayChartsSection({
|
|||||||
<TodayBarChart data={tasksData} />
|
<TodayBarChart data={tasksData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return chartCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${gridClass} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
|
||||||
|
>
|
||||||
|
{chartCards}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
frontend/src/components/today/TodayDonutChart.tsx
Normal file
125
frontend/src/components/today/TodayDonutChart.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_COLORS,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
interface TodayDonutChartProps {
|
||||||
|
data: TodayChartBucket[];
|
||||||
|
labelForCode: (code: string) => string;
|
||||||
|
colorForCode?: (code: string, index: number) => string;
|
||||||
|
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
||||||
|
variant?: 'donut' | 'pie';
|
||||||
|
sideLegend?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayDonutChart({
|
||||||
|
data,
|
||||||
|
labelForCode,
|
||||||
|
colorForCode,
|
||||||
|
swatchStyleForCode,
|
||||||
|
variant = 'donut',
|
||||||
|
sideLegend = false,
|
||||||
|
}: TodayDonutChartProps) {
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
displayLabel: labelForCode(item.code),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolveColor = (code: string, index: number) =>
|
||||||
|
colorForCode?.(code, index) ??
|
||||||
|
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length];
|
||||||
|
|
||||||
|
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
||||||
|
swatchStyleForCode?.(code, index) ?? {
|
||||||
|
backgroundColor: resolveColor(code, index),
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.18)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerRadius = variant === 'pie' ? 0 : 62;
|
||||||
|
const outerRadius = sideLegend ? 100 : 92;
|
||||||
|
|
||||||
|
const chart = (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="displayLabel"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
paddingAngle={variant === 'pie' ? 1 : 2}
|
||||||
|
stroke="transparent"
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const row = item?.payload as TodayChartBucket | undefined;
|
||||||
|
return [value, row ? labelForCode(row.code) : ''];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sideLegend) {
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
||||||
|
const legendInset = 'px-12';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex h-full min-h-[220px] items-center ${legendInset}`}>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center overflow-y-auto max-h-full py-0.5">
|
||||||
|
<div className="flex flex-col items-start gap-1.5 shrink-0">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<span key={entry.code} className={rowClass}>
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-sm border"
|
||||||
|
style={resolveSwatchStyle(entry.code, index)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-col items-start gap-1.5">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
||||||
|
>
|
||||||
|
{entry.displayLabel}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
||||||
|
>
|
||||||
|
{entry.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-4 flex h-[240px] w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
|
||||||
|
{chart}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
78
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { TodayChartBucket } from '@/types/today';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_AXIS_COLOR,
|
||||||
|
TODAY_CHART_COLORS,
|
||||||
|
TODAY_CHART_GRID_COLOR,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
interface TodayHorizontalBarChartProps {
|
||||||
|
data: TodayChartBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) {
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
shortLabel: truncateLabel(item.label, 18),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 36)}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, right: 12, left: 4, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="shortLabel"
|
||||||
|
width={96}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||||
|
return row?.label ?? '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.code}
|
||||||
|
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(label: string, max = 18): string {
|
||||||
|
if (label.length <= max) return label;
|
||||||
|
return `${label.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
56
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
56
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
interface TodayRadialGaugeChartProps {
|
||||||
|
percent: number;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percentLabel: string;
|
||||||
|
tasksLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayRadialGaugeChart({
|
||||||
|
percent,
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
percentLabel,
|
||||||
|
tasksLabel,
|
||||||
|
}: TodayRadialGaugeChartProps) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[240px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadialBarChart
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius="68%"
|
||||||
|
outerRadius="100%"
|
||||||
|
barSize={14}
|
||||||
|
data={data}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
>
|
||||||
|
<RadialBar
|
||||||
|
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
|
||||||
|
dataKey="value"
|
||||||
|
cornerRadius={8}
|
||||||
|
/>
|
||||||
|
</RadialBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-3xl font-semibold text-text-primary">{percentLabel}</span>
|
||||||
|
<span className="mt-1 text-xs text-text-muted">{tasksLabel}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className="mt-0.5 text-[11px] text-text-secondary">
|
||||||
|
{completed}/{total}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,6 @@ export function ChartCardSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListRowSkeleton() {
|
export function ListRowSkeleton({ compact = false }: { compact?: boolean }) {
|
||||||
return <SkeletonBlock className="h-12 w-full" />;
|
return <SkeletonBlock className={`w-full ${compact ? 'h-8' : 'h-12'}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
87
frontend/src/components/today/TodayStackedBarChart.tsx
Normal file
87
frontend/src/components/today/TodayStackedBarChart.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { TodayStackedDayBucket } from '@/types/today';
|
||||||
|
import {
|
||||||
|
TODAY_CHART_AXIS_COLOR,
|
||||||
|
TODAY_CHART_COMPLETED_COLOR,
|
||||||
|
TODAY_CHART_GRID_COLOR,
|
||||||
|
TODAY_CHART_RECEIVED_COLOR,
|
||||||
|
TODAY_CHART_TOOLTIP_STYLE,
|
||||||
|
} from '@/components/today/chart-theme';
|
||||||
|
|
||||||
|
interface TodayStackedBarChartProps {
|
||||||
|
data: TodayStackedDayBucket[];
|
||||||
|
completedLabel: string;
|
||||||
|
receivedLabel: string;
|
||||||
|
formatDayLabel: (code: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayStackedBarChart({
|
||||||
|
data,
|
||||||
|
completedLabel,
|
||||||
|
receivedLabel,
|
||||||
|
formatDayLabel,
|
||||||
|
}: TodayStackedBarChartProps) {
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
dayLabel: formatDayLabel(item.code),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="dayLabel"
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||||
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined;
|
||||||
|
return row ? formatDayLabel(row.code) : '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR }}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="completed"
|
||||||
|
name={completedLabel}
|
||||||
|
stackId="activity"
|
||||||
|
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
maxBarSize={48}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="received"
|
||||||
|
name={receivedLabel}
|
||||||
|
stackId="activity"
|
||||||
|
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={48}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
||||||
import { canAccessAppointmentsSection } from '@/components/shared/permissions';
|
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
|
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
||||||
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import { canViewTreatment } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { TodaySummaryActions } from '@/types/today';
|
import type { TodaySummaryActions } from '@/types/today';
|
||||||
|
|
||||||
interface TodayUpcomingAppointmentsProps {
|
interface TodayUpcomingAppointmentsProps {
|
||||||
@@ -16,6 +22,8 @@ interface TodayUpcomingAppointmentsProps {
|
|||||||
isInitialLoad?: boolean;
|
isInitialLoad?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3;
|
||||||
|
|
||||||
export function TodayUpcomingAppointments({
|
export function TodayUpcomingAppointments({
|
||||||
actions,
|
actions,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -23,23 +31,35 @@ export function TodayUpcomingAppointments({
|
|||||||
}: TodayUpcomingAppointmentsProps) {
|
}: TodayUpcomingAppointmentsProps) {
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
if (!canAccessAppointmentsSection(currentOrganization)) {
|
useEffect(() => {
|
||||||
|
void treatmentCatalogApi
|
||||||
|
.list()
|
||||||
|
.then((response) => setTreatmentCatalog(response.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentOrganization ||
|
||||||
|
currentOrganization.type !== 'CLINIC' ||
|
||||||
|
!canViewTreatment(currentOrganization)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointments = actions.upcomingAppointmentsToday ?? [];
|
const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE);
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<Card className="min-h-[280px]">
|
<Card className="min-h-[140px] p-3">
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-2 space-y-1.5">
|
||||||
<div className="h-4 w-40 animate-pulse rounded bg-background-secondary/60" />
|
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
||||||
<div className="h-3 w-56 animate-pulse rounded bg-background-secondary/60" />
|
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{[0, 1, 2].map((key) => (
|
{[0, 1].map((key) => (
|
||||||
<ListRowSkeleton key={key} />
|
<ListRowSkeleton key={key} compact />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -47,25 +67,25 @@ export function TodayUpcomingAppointments({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`min-h-[280px] ${loading ? 'opacity-70 transition-opacity' : ''}`}>
|
<Card className="min-h-[140px] p-3">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
|
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-card-foreground">
|
<h2 className="text-sm font-semibold text-card-foreground">
|
||||||
{t('upcomingAppointmentsTitle')}
|
{t('upcomingAppointmentsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-text-muted mt-1">{t('upcomingAppointmentsSubtitle')}</p>
|
<p className="mt-0.5 text-[11px] text-text-muted">{t('upcomingAppointmentsSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/appointments"
|
href={treatmentAppointmentHref()}
|
||||||
className="text-xs font-medium text-primary hover:underline underline-offset-2 shrink-0"
|
className="shrink-0 text-[11px] font-medium text-primary hover:underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{t('viewAllAppointments')}
|
{t('viewAllAppointments')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{appointments.length === 0 ? (
|
{appointments.length === 0 ? (
|
||||||
<div className="flex flex-1 min-h-[160px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-4">
|
<div className="flex min-h-[72px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-3">
|
||||||
<p className="text-sm text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/40">
|
<ul className="divide-y divide-border/40">
|
||||||
@@ -73,26 +93,32 @@ export function TodayUpcomingAppointments({
|
|||||||
const start = new Date(appointment.startAt);
|
const start = new Date(appointment.startAt);
|
||||||
const end = new Date(appointment.endAt);
|
const end = new Date(appointment.endAt);
|
||||||
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
||||||
|
const purposeIndex = treatmentCatalog.findIndex((entry) => entry.code === appointment.purpose);
|
||||||
|
const purposeTextColor = treatmentTypeColor(
|
||||||
|
appointment.purpose,
|
||||||
|
purposeIndex < 0 ? 0 : purposeIndex,
|
||||||
|
);
|
||||||
|
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={appointment.id}>
|
<li key={appointment.id}>
|
||||||
<Link
|
<Link
|
||||||
href="/appointments"
|
href={treatmentAppointmentHref(appointment.id)}
|
||||||
className="flex items-center justify-between gap-3 py-3 -mx-2 px-2 rounded-[var(--radius-md)] hover:bg-background-secondary/45 transition-colors group"
|
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">
|
<p className="truncate text-xs font-medium text-text-primary">
|
||||||
{appointment.patientName}
|
{appointment.patientName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted mt-0.5 truncate">
|
<p className="mt-0.5 truncate text-[11px] text-text-muted">
|
||||||
{timeLabel}
|
{timeLabel}
|
||||||
{appointment.purpose ? (
|
{appointment.purpose ? (
|
||||||
<span className="text-text-secondary"> · {appointment.purpose}</span>
|
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="h-4 w-4 shrink-0 text-text-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
34
frontend/src/components/today/chart-day-labels.ts
Normal file
34
frontend/src/components/today/chart-day-labels.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export function useTodayDayLabelFormatter() {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTodayChartDayLabel(
|
||||||
|
code: string,
|
||||||
|
formatter: Intl.DateTimeFormat,
|
||||||
|
): string {
|
||||||
|
const [year, month, day] = code.split('-').map(Number);
|
||||||
|
if (!year || !month || !day) return code;
|
||||||
|
return formatter.format(new Date(year, month - 1, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWeekChartBuckets<T extends { code: string; label: string }>(
|
||||||
|
buckets: T[],
|
||||||
|
formatter: Intl.DateTimeFormat,
|
||||||
|
): T[] {
|
||||||
|
return buckets.map((bucket) => ({
|
||||||
|
...bucket,
|
||||||
|
label: formatTodayChartDayLabel(bucket.code, formatter),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
/** Bar fill colors aligned with the dark dashboard accent palette. */
|
import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors';
|
||||||
export const TODAY_CHART_COLORS = [
|
|
||||||
'#00bcff',
|
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
|
||||||
'#e1bc72',
|
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
|
||||||
'#98d8b5',
|
|
||||||
'#cfb8f7',
|
/** Primary accent for single-series charts (area, gauge). */
|
||||||
'#f8c9a6',
|
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
|
||||||
'#f4b6b6',
|
|
||||||
'#abd8f3',
|
/** Stacked bar segments for lab task activity. */
|
||||||
'#cbe9a1',
|
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
|
||||||
] as const;
|
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
|
||||||
|
|
||||||
export const TODAY_CHART_AXIS_COLOR = '#8ea3bf';
|
export const TODAY_CHART_AXIS_COLOR = '#8ea3bf';
|
||||||
export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)';
|
export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)';
|
||||||
export const TODAY_CHART_TOOLTIP_BG = '#14253d';
|
export const TODAY_CHART_TOOLTIP_BG = '#14253d';
|
||||||
export const TODAY_CHART_TOOLTIP_BORDER = '#29456a';
|
export const TODAY_CHART_TOOLTIP_BORDER = '#29456a';
|
||||||
|
|
||||||
|
export const TODAY_CHART_TOOLTIP_STYLE = {
|
||||||
|
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||||
|
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#f5f9ff',
|
||||||
|
fontSize: '12px',
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
return count === null ? null : String(count);
|
return count === null ? null : String(count);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'providersWithoutWorkingHours',
|
||||||
|
titleKey: 'widgetProvidersWithoutWorkingHours',
|
||||||
|
icon: UserCog,
|
||||||
|
color: 'yellow',
|
||||||
|
orgTypes: ['CLINIC'],
|
||||||
|
href: '/staff',
|
||||||
|
isVisible: (org) => canViewStaff(org),
|
||||||
|
formatValue: (widgets) => {
|
||||||
|
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
||||||
|
return count === null ? null : String(count);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'casesReceivedToday',
|
key: 'casesReceivedToday',
|
||||||
titleKey: 'widgetCasesReceivedToday',
|
titleKey: 'widgetCasesReceivedToday',
|
||||||
@@ -138,6 +151,19 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
|||||||
return count === null ? null : String(count);
|
return count === null ? null : String(count);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'casesInProgress',
|
||||||
|
titleKey: 'widgetCasesInProgress',
|
||||||
|
icon: FlaskConical,
|
||||||
|
color: 'yellow',
|
||||||
|
orgTypes: ['LAB'],
|
||||||
|
href: '/cases',
|
||||||
|
isVisible: (org) => canViewCases(org),
|
||||||
|
formatValue: (widgets) => {
|
||||||
|
const count = countWidget(widgets, 'casesInProgress');
|
||||||
|
return count === null ? null : String(count);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'tasksInProgress',
|
key: 'tasksInProgress',
|
||||||
titleKey: 'widgetTasksInProgress',
|
titleKey: 'widgetTasksInProgress',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||||
@@ -254,10 +255,16 @@ function detailsToPreviewTreatment(
|
|||||||
interface TreatmentWorkspaceProps {
|
interface TreatmentWorkspaceProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
currentOrganization: Organization | null;
|
currentOrganization: Organization | null;
|
||||||
|
initialAppointmentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
|
export function TreatmentWorkspace({
|
||||||
|
userId,
|
||||||
|
currentOrganization,
|
||||||
|
initialAppointmentId = null,
|
||||||
|
}: TreatmentWorkspaceProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
const router = useRouter();
|
||||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||||
const canView = canViewTreatment(currentOrganization);
|
const canView = canViewTreatment(currentOrganization);
|
||||||
const canEdit = canEditTreatment(currentOrganization);
|
const canEdit = canEditTreatment(currentOrganization);
|
||||||
@@ -308,6 +315,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
const labCaseDraftsRef = useRef(labCaseDrafts);
|
const labCaseDraftsRef = useRef(labCaseDrafts);
|
||||||
labCaseDraftsRef.current = labCaseDrafts;
|
labCaseDraftsRef.current = labCaseDrafts;
|
||||||
const skipNextGetDraftRef = useRef(false);
|
const skipNextGetDraftRef = useRef(false);
|
||||||
|
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||||
|
if (initialAppointmentId) {
|
||||||
|
setSelectedDay(startOfLocalDay(new Date()));
|
||||||
|
setSelectionLocked(false);
|
||||||
|
}
|
||||||
|
}, [initialAppointmentId]);
|
||||||
|
|
||||||
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||||
@@ -464,7 +480,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
.map(mapAppointment);
|
.map(mapAppointment);
|
||||||
setAppointments(list);
|
setAppointments(list);
|
||||||
if (!selectionLockedRef.current) {
|
if (!selectionLockedRef.current) {
|
||||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
const pendingId = pendingAppointmentIdRef.current;
|
||||||
|
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
|
||||||
|
setSelectedAppointmentId(pendingId);
|
||||||
|
setSelectionLocked(true);
|
||||||
|
pendingAppointmentIdRef.current = null;
|
||||||
|
router.replace('/treatment', { scroll: false });
|
||||||
|
} else {
|
||||||
|
pendingAppointmentIdRef.current = null;
|
||||||
|
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -477,7 +502,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [userId, selectedDay, showError, t]);
|
}, [userId, selectedDay, showError, t, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = startOfLocalDay(new Date());
|
const today = startOfLocalDay(new Date());
|
||||||
|
|||||||
83
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
83
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
|
||||||
|
* Treatment types own the canonical hex values; prosthesis types reuse the same codes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||||
|
restoration: '#fed7aa',
|
||||||
|
specialized_restoration: '#fdba74',
|
||||||
|
radiography: '#cbd5e1',
|
||||||
|
endo: '#fecaca',
|
||||||
|
surgery: '#fca5a5',
|
||||||
|
prosthesis: '#c4b5fd',
|
||||||
|
implant: '#a5b4fc',
|
||||||
|
orthodontics: '#93c5fd',
|
||||||
|
perio: '#86efac',
|
||||||
|
pediatrics: '#fde68a',
|
||||||
|
extraction: '#f9a8d4',
|
||||||
|
clinic_visit: '#bae6fd',
|
||||||
|
continue_treatment: '#99f6e4',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */
|
||||||
|
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||||
|
pfm_crown: '#cbd5e1',
|
||||||
|
pfz_crown: '#86efac',
|
||||||
|
monolithic_zirconia: '#99f6e4',
|
||||||
|
glass_ceramic_crown: '#fde68a',
|
||||||
|
full_metal_crown: '#cbd5e1',
|
||||||
|
temporary_resin_crown: '#bae6fd',
|
||||||
|
pmma: '#93c5fd',
|
||||||
|
peek_crown: '#99f6e4',
|
||||||
|
veneer_zirconia: '#86efac',
|
||||||
|
veneer_ips_press: '#fed7aa',
|
||||||
|
veneer_ips_cad: '#fdba74',
|
||||||
|
soft_structure: '#ddd6fe',
|
||||||
|
customized_abutment: '#a5b4fc',
|
||||||
|
prefabricated_abutment: '#93c5fd',
|
||||||
|
ti_base_abutment: '#bae6fd',
|
||||||
|
multi_unit_abutment: '#a5b4fc',
|
||||||
|
zirconia_abutment: '#86efac',
|
||||||
|
screw_retained: '#c4b5fd',
|
||||||
|
zirconia_overlay: '#99f6e4',
|
||||||
|
ips_overlay: '#fde68a',
|
||||||
|
smile_design: '#f9a8d4',
|
||||||
|
mockup: '#fbcfe8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATALOG_FALLBACK_COLORS = [
|
||||||
|
'#ddd6fe',
|
||||||
|
'#fed7aa',
|
||||||
|
'#fecaca',
|
||||||
|
'#bae6fd',
|
||||||
|
'#d9f99d',
|
||||||
|
'#fbcfe8',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Ordered palette for charts and rotating unknown catalog codes. */
|
||||||
|
export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
||||||
|
'#fed7aa',
|
||||||
|
'#fdba74',
|
||||||
|
'#cbd5e1',
|
||||||
|
'#fecaca',
|
||||||
|
'#fca5a5',
|
||||||
|
'#c4b5fd',
|
||||||
|
'#a5b4fc',
|
||||||
|
'#93c5fd',
|
||||||
|
'#86efac',
|
||||||
|
'#fde68a',
|
||||||
|
'#f9a8d4',
|
||||||
|
'#bae6fd',
|
||||||
|
'#99f6e4',
|
||||||
|
'#ddd6fe',
|
||||||
|
'#d9f99d',
|
||||||
|
'#fbcfe8',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveCatalogTypeColor(
|
||||||
|
code: string,
|
||||||
|
colorMap: Record<string, string>,
|
||||||
|
index = 0,
|
||||||
|
): string {
|
||||||
|
return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length];
|
||||||
|
}
|
||||||
@@ -1,56 +1,21 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
PROSTHESIS_TYPE_COLORS,
|
||||||
|
resolveCatalogTypeColor,
|
||||||
|
} from '@/components/ui/treatment/catalog-type-colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
||||||
* headers / badges). Grouped by material family, loosely inspired by exocad's
|
* headers / badges). Uses the same hex palette as treatment types.
|
||||||
* material color conventions:
|
|
||||||
* - Zirconia family → pale green/cream
|
|
||||||
* - PFM / full metal → steel gray
|
|
||||||
* - Glass-ceramic / IPS (press & CAD) → warm amber
|
|
||||||
* - Resin / PMMA / PEEK / temporary → mint/teal
|
|
||||||
* - Abutments / screw-retained → slate blue
|
|
||||||
* - Smile design / mockup → lavender/pink
|
|
||||||
*
|
*
|
||||||
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
||||||
*/
|
*/
|
||||||
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
|
||||||
// Zirconia family
|
|
||||||
monolithic_zirconia: '#d9f2e6',
|
|
||||||
pfz_crown: '#c7ede0',
|
|
||||||
veneer_zirconia: '#b8e6d5',
|
|
||||||
zirconia_abutment: '#a7dcc8',
|
|
||||||
zirconia_overlay: '#cdeede',
|
|
||||||
// PFM / metal
|
|
||||||
pfm_crown: '#cbd5e1',
|
|
||||||
full_metal_crown: '#b8c2cf',
|
|
||||||
// Glass-ceramic / IPS
|
|
||||||
glass_ceramic_crown: '#fde3a7',
|
|
||||||
veneer_ips_press: '#fcd88f',
|
|
||||||
veneer_ips_cad: '#f9cf9c',
|
|
||||||
ips_overlay: '#fbe0b0',
|
|
||||||
// Resin / PMMA / PEEK / temporary
|
|
||||||
temporary_resin_crown: '#bfeaf0',
|
|
||||||
pmma: '#a9e2ea',
|
|
||||||
peek_crown: '#b7e4dd',
|
|
||||||
soft_structure: '#d4eef0',
|
|
||||||
// Abutments / screw-retained
|
|
||||||
customized_abutment: '#aec6e8',
|
|
||||||
prefabricated_abutment: '#9db8e0',
|
|
||||||
ti_base_abutment: '#c0d0ec',
|
|
||||||
multi_unit_abutment: '#b4c4e6',
|
|
||||||
screw_retained: '#a8bce2',
|
|
||||||
// Design / mockup
|
|
||||||
smile_design: '#e9d5ff',
|
|
||||||
mockup: '#f5d0fe',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
|
||||||
|
|
||||||
/** Dark ink that stays readable on every pastel in the palette. */
|
/** Dark ink that stays readable on every pastel in the palette. */
|
||||||
const BADGE_INK = '#14253d';
|
const BADGE_INK = '#14253d';
|
||||||
|
|
||||||
export function prosthesisTypeColor(code: string, index = 0): string {
|
export function prosthesisTypeColor(code: string, index = 0): string {
|
||||||
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filled swatch (small indicator dots). */
|
/** Filled swatch (small indicator dots). */
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
import {
|
||||||
|
resolveCatalogTypeColor,
|
||||||
|
TREATMENT_TYPE_COLORS,
|
||||||
|
} from '@/components/ui/treatment/catalog-type-colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for treatment-type colors across the app
|
* Single source of truth for treatment-type colors across the app
|
||||||
@@ -10,23 +14,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|||||||
* conventions), so this is a curated pastel palette. Extend it as new treatment
|
* conventions), so this is a curated pastel palette. Extend it as new treatment
|
||||||
* types are added; unknown codes fall back to a rotating pastel set by index.
|
* types are added; unknown codes fall back to a rotating pastel set by index.
|
||||||
*/
|
*/
|
||||||
const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
|
||||||
restoration: '#fed7aa',
|
|
||||||
specialized_restoration: '#fdba74',
|
|
||||||
radiography: '#cbd5e1',
|
|
||||||
endo: '#fecaca',
|
|
||||||
surgery: '#fca5a5',
|
|
||||||
prosthesis: '#c4b5fd',
|
|
||||||
implant: '#a5b4fc',
|
|
||||||
orthodontics: '#93c5fd',
|
|
||||||
perio: '#86efac',
|
|
||||||
pediatrics: '#fde68a',
|
|
||||||
extraction: '#f9a8d4',
|
|
||||||
clinic_visit: '#bae6fd',
|
|
||||||
continue_treatment: '#99f6e4',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
|
||||||
|
|
||||||
/** Dark ink that stays readable on every pastel in the palette. */
|
/** Dark ink that stays readable on every pastel in the palette. */
|
||||||
const BANNER_INK = '#14253d';
|
const BANNER_INK = '#14253d';
|
||||||
@@ -34,7 +21,7 @@ const BANNER_INK = '#14253d';
|
|||||||
export const DROPDOWN_OPTION_BG = '#14253d';
|
export const DROPDOWN_OPTION_BG = '#14253d';
|
||||||
|
|
||||||
export function treatmentTypeColor(code: string, index = 0): string {
|
export function treatmentTypeColor(code: string, index = 0): string {
|
||||||
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
return resolveCatalogTypeColor(code, TREATMENT_TYPE_COLORS, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filled swatch (legend dots, small indicators). */
|
/** Filled swatch (legend dots, small indicators). */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TodaySummaryResponse } from '@/types/today';
|
|||||||
export interface TodaySummaryParams {
|
export interface TodaySummaryParams {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
utcOffsetMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const todayApi = {
|
export const todayApi = {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const range = getLocalDayIsoRange(new Date());
|
const range = getLocalDayIsoRange(new Date());
|
||||||
const response = await todayApi.summary(range);
|
const utcOffsetMinutes = -new Date().getTimezoneOffset();
|
||||||
|
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
|
||||||
setData(response.data);
|
setData(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err as ApiError);
|
setError(err as ApiError);
|
||||||
|
|||||||
@@ -16,9 +16,26 @@ export type TodayChartBucket = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TodayStackedDayBucket = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
completed: number;
|
||||||
|
received: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TodaySummaryCharts = {
|
export type TodaySummaryCharts = {
|
||||||
treatmentMixWeek?: TodayChartBucket[];
|
treatmentMixWeek?: TodayChartBucket[];
|
||||||
tasksByWorkflowStep?: TodayChartBucket[];
|
tasksByWorkflowStep?: TodayChartBucket[];
|
||||||
|
appointmentsByProvider?: TodayChartBucket[];
|
||||||
|
caseCompletion?: {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
appointmentsWeekAll?: TodayChartBucket[];
|
||||||
|
appointmentsWeekMine?: TodayChartBucket[];
|
||||||
|
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||||
|
inProgressTasksByProsthesis?: TodayChartBucket[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TodayWidgetKey =
|
export type TodayWidgetKey =
|
||||||
@@ -28,11 +45,13 @@ export type TodayWidgetKey =
|
|||||||
| 'draftTreatments'
|
| 'draftTreatments'
|
||||||
| 'labCasesPendingSend'
|
| 'labCasesPendingSend'
|
||||||
| 'casesReceivedToday'
|
| 'casesReceivedToday'
|
||||||
|
| 'casesInProgress'
|
||||||
| 'tasksInProgress'
|
| 'tasksInProgress'
|
||||||
| 'importantTasks'
|
| 'importantTasks'
|
||||||
| 'pendingConnections'
|
| 'pendingConnections'
|
||||||
| 'seats'
|
| 'seats'
|
||||||
| 'pendingStaffInvites';
|
| 'pendingStaffInvites'
|
||||||
|
| 'providersWithoutWorkingHours';
|
||||||
|
|
||||||
export type TodaySummaryWidgets = Partial<
|
export type TodaySummaryWidgets = Partial<
|
||||||
Record<
|
Record<
|
||||||
|
|||||||
Reference in New Issue
Block a user