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