improvement: time period dropdown added to appropriate dashboard charts.

This commit is contained in:
2026-07-19 00:37:59 +03:30
parent 538121d653
commit db515f9630
18 changed files with 557 additions and 95 deletions

View File

@@ -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)

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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": "مشاهده برنامه",

View File

@@ -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",

View File

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

View 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
}
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}

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

View File

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

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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}

View File

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

View File

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