improvement: time period dropdown added to appropriate dashboard charts.
This commit is contained in:
@@ -35,10 +35,21 @@ Use `useRouter` from `@/i18n/navigation` for chart clicks. KPI cards use `Link`/
|
||||
## Charts (LAB examples)
|
||||
|
||||
- **Tasks by prosthesis** — `TodayBarChart` + `colorForCode` from prosthesis catalog; `canViewTasks`
|
||||
- **Cases due this week** — `casesDueWeek` buckets (next 7 local days); active sent cases with due date + in-progress task; `canViewCases`; empty card still shown
|
||||
- **Lab task activity** — stacked week chart; `canViewCases \|\| canViewTasks`
|
||||
- **Cases due** — forward day buckets (next 7 / 30); active sent cases with due date + in-progress task; `canViewCases`; empty card still shown
|
||||
- **Lab task activity** — stacked lookback day chart (7 / 30); `canViewCases \|\| canViewTasks`
|
||||
|
||||
Week day labels: `useTodayDayLabelFormatter` + `mapWeekChartBuckets`.
|
||||
Week/month day labels: `useTodayDayLabelFormatter` + `mapWeekChartBuckets`.
|
||||
|
||||
## Chart period dropdowns
|
||||
|
||||
Seven time-window charts support a compact header `<select>` via `ChartCard` `headerAction` (no taller widgets). Periods persist in `localStorage` (`chart-periods.ts`) and are sent on `GET /today/summary`.
|
||||
|
||||
| Chart | Periods | Direction |
|
||||
|-------|---------|-----------|
|
||||
| Appointments (all / mine), lab task activity, cases due | week, month | lookback (cases due = **forward**) |
|
||||
| Treatment mix, case partners, efficiency | week, month, year | lookback |
|
||||
|
||||
Do **not** put year on day-series charts. Backend: `daysForChartPeriod` + `buildLocalDayBuckets` in `today.service.ts`.
|
||||
|
||||
## Permissions (task KPIs)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { IsIn, IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const WEEK_MONTH = ['week', 'month'] as const;
|
||||
const WEEK_MONTH_YEAR = ['week', 'month', 'year'] as const;
|
||||
|
||||
export class TodaySummaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
|
||||
@@ -30,4 +33,39 @@ export class TodaySummaryQueryDto {
|
||||
@Min(-840)
|
||||
@Max(840)
|
||||
utcOffsetMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH)
|
||||
appointmentsWeekPeriod?: 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH)
|
||||
appointmentsWeekMinePeriod?: 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH)
|
||||
labTaskActivityPeriod?: 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH)
|
||||
casesDuePeriod?: 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH_YEAR)
|
||||
treatmentMixPeriod?: 'week' | 'month' | 'year';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH_YEAR)
|
||||
casePartnersPeriod?: 'week' | 'month' | 'year';
|
||||
|
||||
@ApiPropertyOptional({ enum: WEEK_MONTH_YEAR })
|
||||
@IsOptional()
|
||||
@IsIn(WEEK_MONTH_YEAR)
|
||||
efficiencyPeriod?: 'week' | 'month' | 'year';
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ export class TodayService {
|
||||
organizationId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
resolveWeekMonthPeriod(query.appointmentsWeekPeriod, 'week'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -158,6 +159,7 @@ export class TodayService {
|
||||
userId,
|
||||
!membership.isOwner,
|
||||
to,
|
||||
resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -177,7 +179,13 @@ export class TodayService {
|
||||
);
|
||||
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
||||
tasks.push(
|
||||
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
||||
this.loadTreatmentMixWeek(
|
||||
organizationId,
|
||||
to,
|
||||
locale,
|
||||
resolveWeekMonthYearPeriod(query.treatmentMixPeriod, 'week'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +205,7 @@ export class TodayService {
|
||||
userId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
resolveWeekMonthPeriod(query.appointmentsWeekMinePeriod, 'week'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -234,6 +243,7 @@ export class TodayService {
|
||||
organizationId,
|
||||
from,
|
||||
query.utcOffsetMinutes,
|
||||
resolveWeekMonthPeriod(query.casesDuePeriod, 'week'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -247,6 +257,7 @@ export class TodayService {
|
||||
userId,
|
||||
false,
|
||||
to,
|
||||
resolveWeekMonthYearPeriod(query.casePartnersPeriod, 'month'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -266,6 +277,7 @@ export class TodayService {
|
||||
organizationId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
resolveWeekMonthPeriod(query.labTaskActivityPeriod, 'week'),
|
||||
charts,
|
||||
),
|
||||
);
|
||||
@@ -279,11 +291,19 @@ export class TodayService {
|
||||
}
|
||||
|
||||
if (membership.isOwner) {
|
||||
const efficiencyPeriod = resolveWeekMonthYearPeriod(
|
||||
query.efficiencyPeriod,
|
||||
'month',
|
||||
);
|
||||
if (orgType === 'CLINIC') {
|
||||
tasks.push(this.loadClinicEfficiencyReport(organizationId, to, charts));
|
||||
tasks.push(
|
||||
this.loadClinicEfficiencyReport(organizationId, to, efficiencyPeriod, charts),
|
||||
);
|
||||
}
|
||||
if (orgType === 'LAB') {
|
||||
tasks.push(this.loadLabEfficiencyReport(organizationId, to, charts));
|
||||
tasks.push(
|
||||
this.loadLabEfficiencyReport(organizationId, to, efficiencyPeriod, charts),
|
||||
);
|
||||
}
|
||||
tasks.push(
|
||||
this.buildSubscriptionWidget(organizationId, membership.organization).then(
|
||||
@@ -318,15 +338,18 @@ export class TodayService {
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
locale: CatalogLocale,
|
||||
period: ChartPeriodWeekMonthYear,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000);
|
||||
const windowStart = new Date(
|
||||
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
|
||||
);
|
||||
const grouped = await this.prisma.treatmentDetail.groupBy({
|
||||
by: ['treatmentType'],
|
||||
where: {
|
||||
treatment: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: weekStart, lt: rangeEnd },
|
||||
treatmentAt: { gte: windowStart, lt: rangeEnd },
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
@@ -647,6 +670,7 @@ export class TodayService {
|
||||
private async loadClinicEfficiencyReport(
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
period: ChartPeriodWeekMonthYear,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const members = await this.getActiveEditAccessMembers(
|
||||
@@ -654,12 +678,14 @@ export class TodayService {
|
||||
'TAB_TREATMENT_EDIT',
|
||||
);
|
||||
|
||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||
const windowStart = new Date(
|
||||
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
|
||||
);
|
||||
const grouped = await this.prisma.treatment.groupBy({
|
||||
by: ['providerUserId'],
|
||||
where: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: monthStart, lt: rangeEnd },
|
||||
treatmentAt: { gte: windowStart, lt: rangeEnd },
|
||||
providerUserId: { in: members.map((member) => member.userId) },
|
||||
},
|
||||
_count: { _all: true },
|
||||
@@ -681,6 +707,7 @@ export class TodayService {
|
||||
private async loadLabEfficiencyReport(
|
||||
labOrganizationId: string,
|
||||
rangeEnd: Date,
|
||||
period: ChartPeriodWeekMonthYear,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const members = await this.getActiveEditAccessMembers(
|
||||
@@ -688,12 +715,14 @@ export class TodayService {
|
||||
'TAB_TASKS_EDIT',
|
||||
);
|
||||
|
||||
const monthStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||
const windowStart = new Date(
|
||||
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
|
||||
);
|
||||
const grouped = await this.prisma.labCaseTaskStatusEvent.groupBy({
|
||||
by: ['changedByUserId'],
|
||||
where: {
|
||||
toStatus: LabTaskStatus.COMPLETED,
|
||||
changedAt: { gte: monthStart, lt: rangeEnd },
|
||||
changedAt: { gte: windowStart, lt: rangeEnd },
|
||||
changedByUserId: { in: members.map((member) => member.userId) },
|
||||
task: {
|
||||
labCase: {
|
||||
@@ -926,9 +955,15 @@ export class TodayService {
|
||||
labOrganizationId: string,
|
||||
rangeStart: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
period: ChartPeriodWeekMonth,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const dayBuckets = buildNextSevenLocalDayBuckets(rangeStart, utcOffsetMinutes);
|
||||
const dayBuckets = buildLocalDayBuckets(
|
||||
rangeStart,
|
||||
daysForChartPeriod(period),
|
||||
'forward',
|
||||
utcOffsetMinutes,
|
||||
);
|
||||
const weekStart = dayBuckets[0]?.start ?? rangeStart;
|
||||
const weekEnd = dayBuckets[dayBuckets.length - 1]?.end ?? rangeStart;
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
@@ -1034,12 +1069,14 @@ export class TodayService {
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
period: ChartPeriodWeekMonth,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries(
|
||||
organizationId,
|
||||
rangeEnd,
|
||||
utcOffsetMinutes,
|
||||
period,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1048,12 +1085,14 @@ export class TodayService {
|
||||
providerUserId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
period: ChartPeriodWeekMonth,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries(
|
||||
organizationId,
|
||||
rangeEnd,
|
||||
utcOffsetMinutes,
|
||||
period,
|
||||
providerUserId,
|
||||
);
|
||||
}
|
||||
@@ -1062,9 +1101,15 @@ export class TodayService {
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
period: ChartPeriodWeekMonth,
|
||||
providerUserId?: string,
|
||||
): Promise<ChartBucket[]> {
|
||||
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||
const dayBuckets = buildLocalDayBuckets(
|
||||
rangeEnd,
|
||||
daysForChartPeriod(period),
|
||||
'lookback',
|
||||
utcOffsetMinutes,
|
||||
);
|
||||
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||
const weekEnd = rangeEnd;
|
||||
|
||||
@@ -1101,9 +1146,15 @@ export class TodayService {
|
||||
labOrganizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
period: ChartPeriodWeekMonth,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||
const dayBuckets = buildLocalDayBuckets(
|
||||
rangeEnd,
|
||||
daysForChartPeriod(period),
|
||||
'lookback',
|
||||
utcOffsetMinutes,
|
||||
);
|
||||
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||
const weekEnd = rangeEnd;
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
@@ -1168,9 +1219,12 @@ export class TodayService {
|
||||
userId: string,
|
||||
scopeToUser: boolean,
|
||||
rangeEnd: Date,
|
||||
period: ChartPeriodWeekMonthYear,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const rangeStart = new Date(rangeEnd.getTime() - 30 * 86_400_000);
|
||||
const rangeStart = new Date(
|
||||
rangeEnd.getTime() - daysForChartPeriod(period) * 86_400_000,
|
||||
);
|
||||
|
||||
const cases = await this.prisma.labCase.findMany({
|
||||
where: {
|
||||
@@ -1363,51 +1417,64 @@ function aggregateCount(
|
||||
|
||||
type LocalDayBucket = { code: string; label: string; start: Date; end: Date };
|
||||
|
||||
function buildLastSevenLocalDayBuckets(
|
||||
rangeEnd: Date,
|
||||
type ChartPeriodWeekMonth = 'week' | 'month';
|
||||
type ChartPeriodWeekMonthYear = 'week' | 'month' | 'year';
|
||||
|
||||
function daysForChartPeriod(period: ChartPeriodWeekMonthYear): 7 | 30 | 365 {
|
||||
switch (period) {
|
||||
case 'month':
|
||||
return 30;
|
||||
case 'year':
|
||||
return 365;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWeekMonthPeriod(
|
||||
value: string | undefined,
|
||||
fallback: ChartPeriodWeekMonth,
|
||||
): ChartPeriodWeekMonth {
|
||||
return value === 'week' || value === 'month' ? value : fallback;
|
||||
}
|
||||
|
||||
function resolveWeekMonthYearPeriod(
|
||||
value: string | undefined,
|
||||
fallback: ChartPeriodWeekMonthYear,
|
||||
): ChartPeriodWeekMonthYear {
|
||||
return value === 'week' || value === 'month' || value === 'year' ? value : fallback;
|
||||
}
|
||||
|
||||
function buildLocalDayBuckets(
|
||||
anchor: Date,
|
||||
count: number,
|
||||
direction: 'lookback' | 'forward',
|
||||
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);
|
||||
if (direction === 'lookback') {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const start = new Date(anchor.getTime() - (count - index) * dayMs);
|
||||
const end = new Date(start.getTime() + dayMs);
|
||||
const code = localDayKeyFromDate(start, offsetMs);
|
||||
buckets.push({
|
||||
code,
|
||||
label: code,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
buckets.push({ code, label: code, start, end });
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function buildNextSevenLocalDayBuckets(
|
||||
rangeStart: Date,
|
||||
utcOffsetMinutes?: number,
|
||||
): LocalDayBucket[] {
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
const dayMs = 86_400_000;
|
||||
const localMs = rangeStart.getTime() + offsetMs;
|
||||
const localMs = anchor.getTime() + offsetMs;
|
||||
const local = new Date(localMs);
|
||||
local.setUTCHours(0, 0, 0, 0);
|
||||
const dayStart = new Date(local.getTime() - offsetMs);
|
||||
const buckets: LocalDayBucket[] = [];
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const start = new Date(dayStart.getTime() + index * dayMs);
|
||||
const end = new Date(start.getTime() + dayMs);
|
||||
const code = localDayKeyFromDate(start, offsetMs);
|
||||
buckets.push({
|
||||
code,
|
||||
label: code,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
buckets.push({ code, label: code, start, end });
|
||||
}
|
||||
|
||||
return buckets;
|
||||
|
||||
@@ -224,9 +224,9 @@
|
||||
"subscriptionPeriodPercent": "{percent}%",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} days",
|
||||
"subscriptionNoPlan": "No active plan",
|
||||
"chartAppointmentsWeekAllTitle": "Appointments This Week",
|
||||
"chartAppointmentsWeekAllTitle": "Appointments",
|
||||
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
||||
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
||||
"chartAppointmentsWeekMineTitle": "My Appointments",
|
||||
"chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days",
|
||||
"chartLabTaskActivityTitle": "Lab Task Activity",
|
||||
"chartLabTaskActivitySubtitle": "Last 7 days",
|
||||
@@ -245,7 +245,7 @@
|
||||
"chartTreatmentPlanCompletionRatio": "With treatment plan",
|
||||
"chartTasksByProsthesisTitle": "In-Progress Tasks by Prosthesis",
|
||||
"chartTasksByProsthesisSubtitle": "Current workload mix",
|
||||
"chartCasesDueWeekTitle": "Cases Due This Week",
|
||||
"chartCasesDueWeekTitle": "Cases Due",
|
||||
"chartCasesDueWeekSubtitle": "Active cases with a due date in the next 7 days",
|
||||
"chartCasePartnersClinicTitle": "Cases by Lab",
|
||||
"chartCasePartnersLabTitle": "Cases by Clinic",
|
||||
@@ -256,6 +256,10 @@
|
||||
"chartEfficiencyReportSubtitleClinic": "Treatments created by staff — last 30 days",
|
||||
"chartEfficiencyReportSubtitleLab": "Tasks completed by staff — last 30 days",
|
||||
"chartEmpty": "No data for this period yet.",
|
||||
"periodWeek": "Week",
|
||||
"periodMonth": "Month",
|
||||
"periodYear": "Year",
|
||||
"periodSelectAria": "Chart time period",
|
||||
"upcomingAppointmentsTitle": "Upcoming Today",
|
||||
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
|
||||
"viewAllAppointments": "View schedule",
|
||||
|
||||
@@ -224,9 +224,9 @@
|
||||
"subscriptionPeriodPercent": "{percent}٪",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} روز",
|
||||
"subscriptionNoPlan": "اشتراک فعال نیست",
|
||||
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
||||
"chartAppointmentsWeekAllTitle": "نوبتها",
|
||||
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
||||
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
||||
"chartAppointmentsWeekMineTitle": "نوبتهای من",
|
||||
"chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته",
|
||||
"chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه",
|
||||
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
|
||||
@@ -245,7 +245,7 @@
|
||||
"chartTreatmentPlanCompletionRatio": "دارای طرح درمان",
|
||||
"chartTasksByProsthesisTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||
"chartTasksByProsthesisSubtitle": "ترکیب بار کاری فعلی",
|
||||
"chartCasesDueWeekTitle": "پروندههای موعددار این هفته",
|
||||
"chartCasesDueWeekTitle": "پروندههای موعددار",
|
||||
"chartCasesDueWeekSubtitle": "پروندههای فعال با موعد تحویل در ۷ روز آینده",
|
||||
"chartCasePartnersClinicTitle": "کیسها بر اساس لابراتوار",
|
||||
"chartCasePartnersLabTitle": "کیسها بر اساس کلینیک",
|
||||
@@ -256,6 +256,10 @@
|
||||
"chartEfficiencyReportSubtitleClinic": "درمانهای ثبتشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
"chartEfficiencyReportSubtitleLab": "وظایف تکمیلشده توسط کارکنان — ۳۰ روز گذشته",
|
||||
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
||||
"periodWeek": "هفته",
|
||||
"periodMonth": "ماه",
|
||||
"periodYear": "سال",
|
||||
"periodSelectAria": "بازه زمانی نمودار",
|
||||
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
||||
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
||||
"viewAllAppointments": "مشاهده برنامه",
|
||||
|
||||
@@ -224,9 +224,9 @@
|
||||
"subscriptionPeriodPercent": "{percent}%",
|
||||
"subscriptionPeriodDays": "{elapsed}/{total} dagen",
|
||||
"subscriptionNoPlan": "Geen actief abonnement",
|
||||
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
|
||||
"chartAppointmentsWeekAllTitle": "Afspraken",
|
||||
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
||||
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
||||
"chartAppointmentsWeekMineTitle": "Mijn afspraken",
|
||||
"chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen",
|
||||
"chartLabTaskActivityTitle": "Labtaakactiviteit",
|
||||
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
|
||||
@@ -245,7 +245,7 @@
|
||||
"chartTreatmentPlanCompletionRatio": "Met behandelplan",
|
||||
"chartTasksByProsthesisTitle": "Lopende taken per prothese",
|
||||
"chartTasksByProsthesisSubtitle": "Huidige werklastmix",
|
||||
"chartCasesDueWeekTitle": "Cases met deadline deze week",
|
||||
"chartCasesDueWeekTitle": "Cases met deadline",
|
||||
"chartCasesDueWeekSubtitle": "Actieve cases met een deadline in de komende 7 dagen",
|
||||
"chartCasePartnersClinicTitle": "Cases per lab",
|
||||
"chartCasePartnersLabTitle": "Cases per kliniek",
|
||||
@@ -256,6 +256,10 @@
|
||||
"chartEfficiencyReportSubtitleClinic": "Behandelingen aangemaakt door medewerkers — afgelopen 30 dagen",
|
||||
"chartEfficiencyReportSubtitleLab": "Taken voltooid door medewerkers — afgelopen 30 dagen",
|
||||
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
||||
"periodWeek": "Week",
|
||||
"periodMonth": "Maand",
|
||||
"periodYear": "Jaar",
|
||||
"periodSelectAria": "Tijdvak grafiek",
|
||||
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
|
||||
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
|
||||
"viewAllAppointments": "Bekijk planning",
|
||||
|
||||
@@ -30,3 +30,44 @@ export function mapWeekChartBuckets<T extends { code: string; label: string }>(
|
||||
label: formatTodayChartDayLabel(bucket.code, formatter),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharts XAxis spacing for dense day-series (e.g. 30-day month view).
|
||||
* `preserveStartEnd` keeps first/last ticks; `minTickGap` skips overlaps.
|
||||
*/
|
||||
export function todayChartDenseXAxisProps(pointCount: number): {
|
||||
interval: number | 'preserveStartEnd';
|
||||
minTickGap?: number;
|
||||
fontSize: number;
|
||||
} {
|
||||
if (pointCount <= 8) {
|
||||
return { interval: 0, fontSize: 11 };
|
||||
}
|
||||
if (pointCount <= 14) {
|
||||
return { interval: 1, fontSize: 10 };
|
||||
}
|
||||
return { interval: 'preserveStartEnd', minTickGap: 36, fontSize: 10 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Category bar charts (prosthesis / treatment mix) — fewer bars but longer labels.
|
||||
*/
|
||||
export function todayChartCategoryXAxisProps(pointCount: number): {
|
||||
interval: number | 'preserveStartEnd';
|
||||
minTickGap?: number;
|
||||
fontSize: number;
|
||||
truncateMax: number;
|
||||
} {
|
||||
if (pointCount <= 4) {
|
||||
return { interval: 0, fontSize: 11, truncateMax: 12 };
|
||||
}
|
||||
if (pointCount <= 6) {
|
||||
return { interval: 1, fontSize: 10, truncateMax: 9, minTickGap: 28 };
|
||||
}
|
||||
return {
|
||||
interval: 'preserveStartEnd',
|
||||
minTickGap: 40,
|
||||
fontSize: 10,
|
||||
truncateMax: 8,
|
||||
};
|
||||
}
|
||||
|
||||
103
frontend/src/components/today/chart-periods.ts
Normal file
103
frontend/src/components/today/chart-periods.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
export type TodayChartPeriod = 'week' | 'month' | 'year';
|
||||
|
||||
export type TodayChartPeriodKey =
|
||||
| 'appointmentsWeekAll'
|
||||
| 'appointmentsWeekMine'
|
||||
| 'labTaskActivity'
|
||||
| 'casesDue'
|
||||
| 'treatmentMix'
|
||||
| 'casePartners'
|
||||
| 'efficiency';
|
||||
|
||||
export type TodayChartPeriods = Record<TodayChartPeriodKey, TodayChartPeriod>;
|
||||
|
||||
export const TODAY_CHART_PERIOD_KEYS: TodayChartPeriodKey[] = [
|
||||
'appointmentsWeekAll',
|
||||
'appointmentsWeekMine',
|
||||
'labTaskActivity',
|
||||
'casesDue',
|
||||
'treatmentMix',
|
||||
'casePartners',
|
||||
'efficiency',
|
||||
];
|
||||
|
||||
export const TODAY_CHART_PERIOD_DEFAULTS: TodayChartPeriods = {
|
||||
appointmentsWeekAll: 'week',
|
||||
appointmentsWeekMine: 'week',
|
||||
labTaskActivity: 'week',
|
||||
casesDue: 'week',
|
||||
treatmentMix: 'week',
|
||||
casePartners: 'month',
|
||||
efficiency: 'month',
|
||||
};
|
||||
|
||||
/** Aggregate charts that support year; day-series charts are week/month only. */
|
||||
export const TODAY_CHART_PERIODS_WITH_YEAR: ReadonlySet<TodayChartPeriodKey> = new Set([
|
||||
'treatmentMix',
|
||||
'casePartners',
|
||||
'efficiency',
|
||||
]);
|
||||
|
||||
export function daysForTodayChartPeriod(period: TodayChartPeriod): 7 | 30 | 365 {
|
||||
switch (period) {
|
||||
case 'month':
|
||||
return 30;
|
||||
case 'year':
|
||||
return 365;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
export function allowedPeriodsForChart(
|
||||
key: TodayChartPeriodKey,
|
||||
): readonly TodayChartPeriod[] {
|
||||
return TODAY_CHART_PERIODS_WITH_YEAR.has(key)
|
||||
? (['week', 'month', 'year'] as const)
|
||||
: (['week', 'month'] as const);
|
||||
}
|
||||
|
||||
function storageKey(organizationId: string): string {
|
||||
return `dyolink:today-chart-periods:${organizationId}`;
|
||||
}
|
||||
|
||||
function normalizePeriod(
|
||||
key: TodayChartPeriodKey,
|
||||
value: unknown,
|
||||
): TodayChartPeriod {
|
||||
const allowed = allowedPeriodsForChart(key);
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) {
|
||||
return value as TodayChartPeriod;
|
||||
}
|
||||
return TODAY_CHART_PERIOD_DEFAULTS[key];
|
||||
}
|
||||
|
||||
export function loadTodayChartPeriods(organizationId: string): TodayChartPeriods {
|
||||
if (typeof window === 'undefined') return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(organizationId));
|
||||
if (!raw) return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
const parsed = JSON.parse(raw) as Partial<Record<TodayChartPeriodKey, unknown>>;
|
||||
const next = { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
for (const key of TODAY_CHART_PERIOD_KEYS) {
|
||||
next[key] = normalizePeriod(key, parsed[key]);
|
||||
}
|
||||
return next;
|
||||
} catch {
|
||||
return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTodayChartPeriods(
|
||||
organizationId: string,
|
||||
periods: TodayChartPeriods,
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(organizationId), JSON.stringify(periods));
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,48 @@ import { ChartCardSkeleton } from '@/components/ui/today/TodaySkeleton';
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Compact control in the title row (e.g. period select) — does not grow card height. */
|
||||
headerAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel).
|
||||
* Chart column is independent and vertically centered.
|
||||
* Three-column layout for chart + legend gadgets:
|
||||
* 1) title + legends, 2) centered chart panel, 3) header action (period select).
|
||||
* Column order is grid flow — RTL reverses naturally with `dir`.
|
||||
*/
|
||||
sidePanelLayout?: boolean;
|
||||
chartPanel?: ReactNode;
|
||||
}
|
||||
|
||||
function ChartCardHeader({
|
||||
function ChartCardTitleBlock({
|
||||
title,
|
||||
subtitle,
|
||||
}: Pick<ChartCardProps, 'title' | 'subtitle'>) {
|
||||
return (
|
||||
<div className="mb-3 shrink-0">
|
||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
headerAction,
|
||||
}: Pick<ChartCardProps, 'title' | 'subtitle' | 'headerAction'>) {
|
||||
return (
|
||||
<div className="mb-3 shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
|
||||
</div>
|
||||
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -32,6 +55,7 @@ function ChartCardHeader({
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
headerAction,
|
||||
children,
|
||||
emptyMessage,
|
||||
isEmpty = false,
|
||||
@@ -45,9 +69,10 @@ export function ChartCard({
|
||||
|
||||
if (sidePanelLayout) {
|
||||
return (
|
||||
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
|
||||
<Card className="grid h-full min-h-0 grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_auto] gap-x-3 overflow-hidden">
|
||||
{/* Col 1 — title + legends (unchanged stacking) */}
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
<ChartCardTitleBlock title={title} subtitle={subtitle} />
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
|
||||
@@ -59,20 +84,28 @@ export function ChartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 2 — pie / chart, centered */}
|
||||
{!isEmpty && chartPanel ? (
|
||||
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
|
||||
<div className="aspect-square h-full max-h-full w-full max-w-full">
|
||||
{chartPanel}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="min-h-0" aria-hidden />
|
||||
)}
|
||||
|
||||
{/* Col 3 — period select at top, aligned to the outer end like other gadgets */}
|
||||
<div className="flex shrink-0 flex-col items-end self-start pt-0.5">
|
||||
{headerAction}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
<ChartCardHeader title={title} subtitle={subtitle} headerAction={headerAction} />
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TODAY_CHART_PRIMARY_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
|
||||
|
||||
interface TodayAreaChartProps {
|
||||
data: TodayChartBucket[];
|
||||
@@ -31,10 +32,12 @@ export function TodayAreaChart({
|
||||
gradientId = 'todayAreaFill',
|
||||
showXAxis = true,
|
||||
}: TodayAreaChartProps) {
|
||||
const xAxis = todayChartDenseXAxisProps(data.length);
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 2 : -4 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
|
||||
@@ -45,10 +48,11 @@ export function TodayAreaChart({
|
||||
{showXAxis ? (
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
) : (
|
||||
<XAxis dataKey="label" hide />
|
||||
@@ -71,7 +75,7 @@ export function TodayAreaChart({
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||
dot={data.length <= 14 ? { r: 3, fill: color, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: color }}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TODAY_CHART_TOOLTIP_BG,
|
||||
TODAY_CHART_TOOLTIP_BORDER,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartCategoryXAxisProps } from '@/components/today/chart-day-labels';
|
||||
|
||||
interface TodayBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
@@ -28,9 +29,10 @@ interface TodayBarChartProps {
|
||||
}
|
||||
|
||||
export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartProps) {
|
||||
const xAxis = todayChartCategoryXAxisProps(data.length);
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
shortLabel: truncateLabel(item.label, xAxis.truncateMax),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -61,20 +63,21 @@ export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartP
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
dy={14}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
fontSize={11}
|
||||
fontSize={xAxis.fontSize}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }
|
||||
}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
|
||||
51
frontend/src/components/ui/today/TodayChartPeriodSelect.tsx
Normal file
51
frontend/src/components/ui/today/TodayChartPeriodSelect.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import type { TodayChartPeriod } from '@/components/today/chart-periods';
|
||||
|
||||
type TodayChartPeriodSelectProps = {
|
||||
value: TodayChartPeriod;
|
||||
options: readonly TodayChartPeriod[];
|
||||
onChange: (period: TodayChartPeriod) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function TodayChartPeriodSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: TodayChartPeriodSelectProps) {
|
||||
const t = useTranslations('today');
|
||||
|
||||
const labelFor = (period: TodayChartPeriod) => {
|
||||
switch (period) {
|
||||
case 'month':
|
||||
return t('periodMonth');
|
||||
case 'year':
|
||||
return t('periodYear');
|
||||
default:
|
||||
return t('periodWeek');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={t('periodSelectAria')}
|
||||
className={`${FORM_SELECT_CLASS} max-w-[7.5rem] shrink-0 py-0.5 text-xs`}
|
||||
onChange={(event) => onChange(event.target.value as TodayChartPeriod)}
|
||||
>
|
||||
{options.map((period) => (
|
||||
<option key={period} value={period}>
|
||||
{labelFor(period)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
} from '@/components/shared/permissions';
|
||||
import { KpiCard } from '@/components/ui/today/KpiCard';
|
||||
import { ChartCard } from '@/components/ui/today/ChartCard';
|
||||
import { TodayChartPeriodSelect } from '@/components/ui/today/TodayChartPeriodSelect';
|
||||
import {
|
||||
allowedPeriodsForChart,
|
||||
type TodayChartPeriod,
|
||||
type TodayChartPeriodKey,
|
||||
type TodayChartPeriods,
|
||||
} from '@/components/today/chart-periods';
|
||||
import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/ui/today/TodayBarChart';
|
||||
import {
|
||||
@@ -62,6 +69,8 @@ interface TodayDashboardProps {
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
@@ -72,6 +81,8 @@ export function TodayDashboard({
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
@@ -161,6 +172,8 @@ export function TodayDashboard({
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||
showCaseCompletionCard:
|
||||
@@ -195,6 +208,8 @@ export function TodayDashboard({
|
||||
orgType,
|
||||
isOwner,
|
||||
charts,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
@@ -314,6 +329,8 @@ function buildDashboardCells(options: {
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
||||
showSubscriptionCard: boolean;
|
||||
showCaseCompletionCard: boolean;
|
||||
@@ -348,6 +365,8 @@ function buildDashboardCells(options: {
|
||||
canEditCases(options.currentOrganization))),
|
||||
dayLabelFormatter: options.dayLabelFormatter,
|
||||
prosthesisCatalog: options.prosthesisCatalog,
|
||||
chartPeriods: options.chartPeriods,
|
||||
onChartPeriodChange: options.onChartPeriodChange,
|
||||
onTasksProsthesisClick: options.onTasksProsthesisClick,
|
||||
onCasePartnerClick: options.onCasePartnerClick,
|
||||
}),
|
||||
@@ -438,10 +457,21 @@ function buildChartCells(options: {
|
||||
showCasePartnersChart: boolean;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
onTasksProsthesisClick?: (code: string) => void;
|
||||
onCasePartnerClick?: (code: string) => void;
|
||||
}): TodayDashboardCell[] {
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart, chartPeriods } = options;
|
||||
|
||||
const periodSelect = (key: TodayChartPeriodKey) => (
|
||||
<TodayChartPeriodSelect
|
||||
value={chartPeriods[key]}
|
||||
options={allowedPeriodsForChart(key)}
|
||||
onChange={(period) => options.onChartPeriodChange(key, period)}
|
||||
/>
|
||||
);
|
||||
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
|
||||
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
|
||||
@@ -467,7 +497,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
headerAction={periodSelect('appointmentsWeekAll')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -488,7 +518,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekMineTitle')}
|
||||
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||
headerAction={periodSelect('appointmentsWeekMine')}
|
||||
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -505,7 +535,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
headerAction={periodSelect('labTaskActivity')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
@@ -533,11 +563,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartEfficiencyReportTitle')}
|
||||
subtitle={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartEfficiencyReportSubtitleClinic')
|
||||
: t('chartEfficiencyReportSubtitleLab')
|
||||
}
|
||||
headerAction={periodSelect('efficiency')}
|
||||
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
sidePanelLayout
|
||||
@@ -588,7 +614,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartTreatmentMixTitle')}
|
||||
subtitle={t('chartTreatmentMixSubtitle')}
|
||||
headerAction={periodSelect('treatmentMix')}
|
||||
isEmpty={treatmentData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -638,7 +664,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartCasesDueWeekTitle')}
|
||||
subtitle={t('chartCasesDueWeekSubtitle')}
|
||||
headerAction={periodSelect('casesDue')}
|
||||
isEmpty={casesDueWeekData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -660,7 +686,7 @@ function buildChartCells(options: {
|
||||
? t('chartCasePartnersClinicTitle')
|
||||
: t('chartCasePartnersLabTitle')
|
||||
}
|
||||
subtitle={t('chartCasePartnersSubtitle')}
|
||||
headerAction={periodSelect('casePartners')}
|
||||
isEmpty={casePartnersData.every(
|
||||
(row) => row.completed === 0 && row.pending === 0,
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TODAY_CHART_RECEIVED_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
|
||||
import type { TodayStackedDayBucket } from '@/types/today';
|
||||
|
||||
export type LabTaskActivityChartRow = {
|
||||
@@ -36,12 +37,15 @@ export function TodayLabTaskActivityChart({
|
||||
completedLabel,
|
||||
receivedLabel,
|
||||
}: TodayLabTaskActivityChartProps) {
|
||||
const xAxis = todayChartDenseXAxisProps(data.length);
|
||||
const showDots = data.length <= 14;
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 2 }}>
|
||||
<defs>
|
||||
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
|
||||
@@ -55,10 +59,11 @@ export function TodayLabTaskActivityChart({
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
@@ -79,7 +84,7 @@ export function TodayLabTaskActivityChart({
|
||||
stroke={TODAY_CHART_COMPLETED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskCompletedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
|
||||
dot={showDots ? { r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
|
||||
/>
|
||||
<Area
|
||||
@@ -89,7 +94,7 @@ export function TodayLabTaskActivityChart({
|
||||
stroke={TODAY_CHART_RECEIVED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskReceivedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
|
||||
dot={showDots ? { r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
||||
@@ -18,7 +18,15 @@ export function TodayPage() {
|
||||
const tErrors = useTranslations('errors');
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgId = currentOrganization?.id;
|
||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
error,
|
||||
chartPeriods,
|
||||
setChartPeriod,
|
||||
reload,
|
||||
} = useTodaySummary(orgId);
|
||||
|
||||
const showNoSubscriptionNotice = useMemo(
|
||||
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
|
||||
@@ -69,6 +77,8 @@ export function TodayPage() {
|
||||
charts={data?.charts ?? {}}
|
||||
actions={data?.actions ?? {}}
|
||||
subscription={data?.subscription}
|
||||
chartPeriods={chartPeriods}
|
||||
onChartPeriodChange={setChartPeriod}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
hasError={Boolean(error)}
|
||||
|
||||
@@ -55,7 +55,8 @@ export function TodayPartnerCasesStackedBarChart({
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
interval={chartData.length <= 8 ? 0 : 'preserveStartEnd'}
|
||||
minTickGap={chartData.length > 8 ? 28 : undefined}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { apiClient } from './client';
|
||||
import type { TodaySummaryResponse } from '@/types/today';
|
||||
import type { TodayChartPeriods } from '@/components/today/chart-periods';
|
||||
|
||||
export interface TodaySummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
utcOffsetMinutes?: number;
|
||||
chartPeriods?: TodayChartPeriods;
|
||||
}
|
||||
|
||||
function chartPeriodQuery(periods?: TodayChartPeriods) {
|
||||
if (!periods) return {};
|
||||
return {
|
||||
appointmentsWeekPeriod: periods.appointmentsWeekAll,
|
||||
appointmentsWeekMinePeriod: periods.appointmentsWeekMine,
|
||||
labTaskActivityPeriod: periods.labTaskActivity,
|
||||
casesDuePeriod: periods.casesDue,
|
||||
treatmentMixPeriod: periods.treatmentMix,
|
||||
casePartnersPeriod: periods.casePartners,
|
||||
efficiencyPeriod: periods.efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
export const todayApi = {
|
||||
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
|
||||
const response = await apiClient.get('/today/summary', { params });
|
||||
const { chartPeriods, ...range } = params;
|
||||
const response = await apiClient.get('/today/summary', {
|
||||
params: { ...range, ...chartPeriodQuery(chartPeriods) },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
loadTodayChartPeriods,
|
||||
saveTodayChartPeriods,
|
||||
type TodayChartPeriod,
|
||||
type TodayChartPeriodKey,
|
||||
type TodayChartPeriods,
|
||||
TODAY_CHART_PERIOD_DEFAULTS,
|
||||
} from '@/components/today/chart-periods';
|
||||
import { todayApi } from '@/lib/api/today';
|
||||
import type { TodaySummaryData } from '@/types/today';
|
||||
import type { ApiError } from '@/types/api';
|
||||
@@ -11,6 +19,8 @@ interface UseTodaySummaryResult {
|
||||
loading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
error: ApiError | null;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
setChartPeriod: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -19,6 +29,17 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
const [data, setData] = useState<TodaySummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [chartPeriods, setChartPeriods] = useState<TodayChartPeriods>(() =>
|
||||
organizationId ? loadTodayChartPeriods(organizationId) : TODAY_CHART_PERIOD_DEFAULTS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setChartPeriods(TODAY_CHART_PERIOD_DEFAULTS);
|
||||
return;
|
||||
}
|
||||
setChartPeriods(loadTodayChartPeriods(organizationId));
|
||||
}, [organizationId]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!organizationId) {
|
||||
@@ -34,14 +55,18 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
try {
|
||||
const range = getLocalDayIsoRange(new Date());
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
|
||||
const response = await todayApi.summary({
|
||||
...range,
|
||||
utcOffsetMinutes,
|
||||
chartPeriods,
|
||||
});
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err as ApiError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [organizationId]);
|
||||
}, [organizationId, chartPeriods]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
@@ -54,11 +79,25 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
void reload();
|
||||
}, [organizationId, reload]);
|
||||
|
||||
const setChartPeriod = useCallback(
|
||||
(key: TodayChartPeriodKey, period: TodayChartPeriod) => {
|
||||
setChartPeriods((prev) => {
|
||||
if (prev[key] === period) return prev;
|
||||
const next = { ...prev, [key]: period };
|
||||
if (organizationId) saveTodayChartPeriods(organizationId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[organizationId],
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad: loading && !data,
|
||||
error,
|
||||
chartPeriods,
|
||||
setChartPeriod,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user