improvement: time period dropdown added to appropriate dashboard charts.
This commit is contained in:
@@ -30,3 +30,44 @@ export function mapWeekChartBuckets<T extends { code: string; label: string }>(
|
||||
label: formatTodayChartDayLabel(bucket.code, formatter),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharts XAxis spacing for dense day-series (e.g. 30-day month view).
|
||||
* `preserveStartEnd` keeps first/last ticks; `minTickGap` skips overlaps.
|
||||
*/
|
||||
export function todayChartDenseXAxisProps(pointCount: number): {
|
||||
interval: number | 'preserveStartEnd';
|
||||
minTickGap?: number;
|
||||
fontSize: number;
|
||||
} {
|
||||
if (pointCount <= 8) {
|
||||
return { interval: 0, fontSize: 11 };
|
||||
}
|
||||
if (pointCount <= 14) {
|
||||
return { interval: 1, fontSize: 10 };
|
||||
}
|
||||
return { interval: 'preserveStartEnd', minTickGap: 36, fontSize: 10 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Category bar charts (prosthesis / treatment mix) — fewer bars but longer labels.
|
||||
*/
|
||||
export function todayChartCategoryXAxisProps(pointCount: number): {
|
||||
interval: number | 'preserveStartEnd';
|
||||
minTickGap?: number;
|
||||
fontSize: number;
|
||||
truncateMax: number;
|
||||
} {
|
||||
if (pointCount <= 4) {
|
||||
return { interval: 0, fontSize: 11, truncateMax: 12 };
|
||||
}
|
||||
if (pointCount <= 6) {
|
||||
return { interval: 1, fontSize: 10, truncateMax: 9, minTickGap: 28 };
|
||||
}
|
||||
return {
|
||||
interval: 'preserveStartEnd',
|
||||
minTickGap: 40,
|
||||
fontSize: 10,
|
||||
truncateMax: 8,
|
||||
};
|
||||
}
|
||||
|
||||
103
frontend/src/components/today/chart-periods.ts
Normal file
103
frontend/src/components/today/chart-periods.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
export type TodayChartPeriod = 'week' | 'month' | 'year';
|
||||
|
||||
export type TodayChartPeriodKey =
|
||||
| 'appointmentsWeekAll'
|
||||
| 'appointmentsWeekMine'
|
||||
| 'labTaskActivity'
|
||||
| 'casesDue'
|
||||
| 'treatmentMix'
|
||||
| 'casePartners'
|
||||
| 'efficiency';
|
||||
|
||||
export type TodayChartPeriods = Record<TodayChartPeriodKey, TodayChartPeriod>;
|
||||
|
||||
export const TODAY_CHART_PERIOD_KEYS: TodayChartPeriodKey[] = [
|
||||
'appointmentsWeekAll',
|
||||
'appointmentsWeekMine',
|
||||
'labTaskActivity',
|
||||
'casesDue',
|
||||
'treatmentMix',
|
||||
'casePartners',
|
||||
'efficiency',
|
||||
];
|
||||
|
||||
export const TODAY_CHART_PERIOD_DEFAULTS: TodayChartPeriods = {
|
||||
appointmentsWeekAll: 'week',
|
||||
appointmentsWeekMine: 'week',
|
||||
labTaskActivity: 'week',
|
||||
casesDue: 'week',
|
||||
treatmentMix: 'week',
|
||||
casePartners: 'month',
|
||||
efficiency: 'month',
|
||||
};
|
||||
|
||||
/** Aggregate charts that support year; day-series charts are week/month only. */
|
||||
export const TODAY_CHART_PERIODS_WITH_YEAR: ReadonlySet<TodayChartPeriodKey> = new Set([
|
||||
'treatmentMix',
|
||||
'casePartners',
|
||||
'efficiency',
|
||||
]);
|
||||
|
||||
export function daysForTodayChartPeriod(period: TodayChartPeriod): 7 | 30 | 365 {
|
||||
switch (period) {
|
||||
case 'month':
|
||||
return 30;
|
||||
case 'year':
|
||||
return 365;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
export function allowedPeriodsForChart(
|
||||
key: TodayChartPeriodKey,
|
||||
): readonly TodayChartPeriod[] {
|
||||
return TODAY_CHART_PERIODS_WITH_YEAR.has(key)
|
||||
? (['week', 'month', 'year'] as const)
|
||||
: (['week', 'month'] as const);
|
||||
}
|
||||
|
||||
function storageKey(organizationId: string): string {
|
||||
return `dyolink:today-chart-periods:${organizationId}`;
|
||||
}
|
||||
|
||||
function normalizePeriod(
|
||||
key: TodayChartPeriodKey,
|
||||
value: unknown,
|
||||
): TodayChartPeriod {
|
||||
const allowed = allowedPeriodsForChart(key);
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) {
|
||||
return value as TodayChartPeriod;
|
||||
}
|
||||
return TODAY_CHART_PERIOD_DEFAULTS[key];
|
||||
}
|
||||
|
||||
export function loadTodayChartPeriods(organizationId: string): TodayChartPeriods {
|
||||
if (typeof window === 'undefined') return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(organizationId));
|
||||
if (!raw) return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
const parsed = JSON.parse(raw) as Partial<Record<TodayChartPeriodKey, unknown>>;
|
||||
const next = { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
for (const key of TODAY_CHART_PERIOD_KEYS) {
|
||||
next[key] = normalizePeriod(key, parsed[key]);
|
||||
}
|
||||
return next;
|
||||
} catch {
|
||||
return { ...TODAY_CHART_PERIOD_DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTodayChartPeriods(
|
||||
organizationId: string,
|
||||
periods: TodayChartPeriods,
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(organizationId), JSON.stringify(periods));
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,48 @@ import { ChartCardSkeleton } from '@/components/ui/today/TodaySkeleton';
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Compact control in the title row (e.g. period select) — does not grow card height. */
|
||||
headerAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel).
|
||||
* Chart column is independent and vertically centered.
|
||||
* Three-column layout for chart + legend gadgets:
|
||||
* 1) title + legends, 2) centered chart panel, 3) header action (period select).
|
||||
* Column order is grid flow — RTL reverses naturally with `dir`.
|
||||
*/
|
||||
sidePanelLayout?: boolean;
|
||||
chartPanel?: ReactNode;
|
||||
}
|
||||
|
||||
function ChartCardHeader({
|
||||
function ChartCardTitleBlock({
|
||||
title,
|
||||
subtitle,
|
||||
}: Pick<ChartCardProps, 'title' | 'subtitle'>) {
|
||||
return (
|
||||
<div className="mb-3 shrink-0">
|
||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
headerAction,
|
||||
}: Pick<ChartCardProps, 'title' | 'subtitle' | 'headerAction'>) {
|
||||
return (
|
||||
<div className="mb-3 shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
|
||||
</div>
|
||||
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -32,6 +55,7 @@ function ChartCardHeader({
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
headerAction,
|
||||
children,
|
||||
emptyMessage,
|
||||
isEmpty = false,
|
||||
@@ -45,9 +69,10 @@ export function ChartCard({
|
||||
|
||||
if (sidePanelLayout) {
|
||||
return (
|
||||
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
|
||||
<Card className="grid h-full min-h-0 grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_auto] gap-x-3 overflow-hidden">
|
||||
{/* Col 1 — title + legends (unchanged stacking) */}
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
<ChartCardTitleBlock title={title} subtitle={subtitle} />
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
|
||||
@@ -59,20 +84,28 @@ export function ChartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 2 — pie / chart, centered */}
|
||||
{!isEmpty && chartPanel ? (
|
||||
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
|
||||
<div className="aspect-square h-full max-h-full w-full max-w-full">
|
||||
{chartPanel}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="min-h-0" aria-hidden />
|
||||
)}
|
||||
|
||||
{/* Col 3 — period select at top, aligned to the outer end like other gadgets */}
|
||||
<div className="flex shrink-0 flex-col items-end self-start pt-0.5">
|
||||
{headerAction}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
<ChartCardHeader title={title} subtitle={subtitle} headerAction={headerAction} />
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TODAY_CHART_PRIMARY_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
|
||||
|
||||
interface TodayAreaChartProps {
|
||||
data: TodayChartBucket[];
|
||||
@@ -31,10 +32,12 @@ export function TodayAreaChart({
|
||||
gradientId = 'todayAreaFill',
|
||||
showXAxis = true,
|
||||
}: TodayAreaChartProps) {
|
||||
const xAxis = todayChartDenseXAxisProps(data.length);
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 2 : -4 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
|
||||
@@ -45,10 +48,11 @@ export function TodayAreaChart({
|
||||
{showXAxis ? (
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
) : (
|
||||
<XAxis dataKey="label" hide />
|
||||
@@ -71,7 +75,7 @@ export function TodayAreaChart({
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||
dot={data.length <= 14 ? { r: 3, fill: color, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: color }}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TODAY_CHART_TOOLTIP_BG,
|
||||
TODAY_CHART_TOOLTIP_BORDER,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartCategoryXAxisProps } from '@/components/today/chart-day-labels';
|
||||
|
||||
interface TodayBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
@@ -28,9 +29,10 @@ interface TodayBarChartProps {
|
||||
}
|
||||
|
||||
export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartProps) {
|
||||
const xAxis = todayChartCategoryXAxisProps(data.length);
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
shortLabel: truncateLabel(item.label, xAxis.truncateMax),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -61,20 +63,21 @@ export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartP
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
dy={14}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
fontSize={11}
|
||||
fontSize={xAxis.fontSize}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }
|
||||
}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
|
||||
51
frontend/src/components/ui/today/TodayChartPeriodSelect.tsx
Normal file
51
frontend/src/components/ui/today/TodayChartPeriodSelect.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import type { TodayChartPeriod } from '@/components/today/chart-periods';
|
||||
|
||||
type TodayChartPeriodSelectProps = {
|
||||
value: TodayChartPeriod;
|
||||
options: readonly TodayChartPeriod[];
|
||||
onChange: (period: TodayChartPeriod) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function TodayChartPeriodSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: TodayChartPeriodSelectProps) {
|
||||
const t = useTranslations('today');
|
||||
|
||||
const labelFor = (period: TodayChartPeriod) => {
|
||||
switch (period) {
|
||||
case 'month':
|
||||
return t('periodMonth');
|
||||
case 'year':
|
||||
return t('periodYear');
|
||||
default:
|
||||
return t('periodWeek');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={t('periodSelectAria')}
|
||||
className={`${FORM_SELECT_CLASS} max-w-[7.5rem] shrink-0 py-0.5 text-xs`}
|
||||
onChange={(event) => onChange(event.target.value as TodayChartPeriod)}
|
||||
>
|
||||
{options.map((period) => (
|
||||
<option key={period} value={period}>
|
||||
{labelFor(period)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
} from '@/components/shared/permissions';
|
||||
import { KpiCard } from '@/components/ui/today/KpiCard';
|
||||
import { ChartCard } from '@/components/ui/today/ChartCard';
|
||||
import { TodayChartPeriodSelect } from '@/components/ui/today/TodayChartPeriodSelect';
|
||||
import {
|
||||
allowedPeriodsForChart,
|
||||
type TodayChartPeriod,
|
||||
type TodayChartPeriodKey,
|
||||
type TodayChartPeriods,
|
||||
} from '@/components/today/chart-periods';
|
||||
import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/ui/today/TodayBarChart';
|
||||
import {
|
||||
@@ -62,6 +69,8 @@ interface TodayDashboardProps {
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
@@ -72,6 +81,8 @@ export function TodayDashboard({
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
@@ -161,6 +172,8 @@ export function TodayDashboard({
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||
showCaseCompletionCard:
|
||||
@@ -195,6 +208,8 @@ export function TodayDashboard({
|
||||
orgType,
|
||||
isOwner,
|
||||
charts,
|
||||
chartPeriods,
|
||||
onChartPeriodChange,
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
@@ -314,6 +329,8 @@ function buildDashboardCells(options: {
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
||||
showSubscriptionCard: boolean;
|
||||
showCaseCompletionCard: boolean;
|
||||
@@ -348,6 +365,8 @@ function buildDashboardCells(options: {
|
||||
canEditCases(options.currentOrganization))),
|
||||
dayLabelFormatter: options.dayLabelFormatter,
|
||||
prosthesisCatalog: options.prosthesisCatalog,
|
||||
chartPeriods: options.chartPeriods,
|
||||
onChartPeriodChange: options.onChartPeriodChange,
|
||||
onTasksProsthesisClick: options.onTasksProsthesisClick,
|
||||
onCasePartnerClick: options.onCasePartnerClick,
|
||||
}),
|
||||
@@ -438,10 +457,21 @@ function buildChartCells(options: {
|
||||
showCasePartnersChart: boolean;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
chartPeriods: TodayChartPeriods;
|
||||
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
onTasksProsthesisClick?: (code: string) => void;
|
||||
onCasePartnerClick?: (code: string) => void;
|
||||
}): TodayDashboardCell[] {
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart, chartPeriods } = options;
|
||||
|
||||
const periodSelect = (key: TodayChartPeriodKey) => (
|
||||
<TodayChartPeriodSelect
|
||||
value={chartPeriods[key]}
|
||||
options={allowedPeriodsForChart(key)}
|
||||
onChange={(period) => options.onChartPeriodChange(key, period)}
|
||||
/>
|
||||
);
|
||||
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
|
||||
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
|
||||
@@ -467,7 +497,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
headerAction={periodSelect('appointmentsWeekAll')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -488,7 +518,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekMineTitle')}
|
||||
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||
headerAction={periodSelect('appointmentsWeekMine')}
|
||||
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -505,7 +535,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
headerAction={periodSelect('labTaskActivity')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
@@ -533,11 +563,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartEfficiencyReportTitle')}
|
||||
subtitle={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartEfficiencyReportSubtitleClinic')
|
||||
: t('chartEfficiencyReportSubtitleLab')
|
||||
}
|
||||
headerAction={periodSelect('efficiency')}
|
||||
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
sidePanelLayout
|
||||
@@ -588,7 +614,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartTreatmentMixTitle')}
|
||||
subtitle={t('chartTreatmentMixSubtitle')}
|
||||
headerAction={periodSelect('treatmentMix')}
|
||||
isEmpty={treatmentData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -638,7 +664,7 @@ function buildChartCells(options: {
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartCasesDueWeekTitle')}
|
||||
subtitle={t('chartCasesDueWeekSubtitle')}
|
||||
headerAction={periodSelect('casesDue')}
|
||||
isEmpty={casesDueWeekData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
@@ -660,7 +686,7 @@ function buildChartCells(options: {
|
||||
? t('chartCasePartnersClinicTitle')
|
||||
: t('chartCasePartnersLabTitle')
|
||||
}
|
||||
subtitle={t('chartCasePartnersSubtitle')}
|
||||
headerAction={periodSelect('casePartners')}
|
||||
isEmpty={casePartnersData.every(
|
||||
(row) => row.completed === 0 && row.pending === 0,
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TODAY_CHART_RECEIVED_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
|
||||
import type { TodayStackedDayBucket } from '@/types/today';
|
||||
|
||||
export type LabTaskActivityChartRow = {
|
||||
@@ -36,12 +37,15 @@ export function TodayLabTaskActivityChart({
|
||||
completedLabel,
|
||||
receivedLabel,
|
||||
}: TodayLabTaskActivityChartProps) {
|
||||
const xAxis = todayChartDenseXAxisProps(data.length);
|
||||
const showDots = data.length <= 14;
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 2 }}>
|
||||
<defs>
|
||||
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
|
||||
@@ -55,10 +59,11 @@ export function TodayLabTaskActivityChart({
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
interval={xAxis.interval}
|
||||
minTickGap={xAxis.minTickGap}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
@@ -79,7 +84,7 @@ export function TodayLabTaskActivityChart({
|
||||
stroke={TODAY_CHART_COMPLETED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskCompletedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
|
||||
dot={showDots ? { r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
|
||||
/>
|
||||
<Area
|
||||
@@ -89,7 +94,7 @@ export function TodayLabTaskActivityChart({
|
||||
stroke={TODAY_CHART_RECEIVED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskReceivedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
|
||||
dot={showDots ? { r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 } : false}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
||||
@@ -18,7 +18,15 @@ export function TodayPage() {
|
||||
const tErrors = useTranslations('errors');
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgId = currentOrganization?.id;
|
||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
error,
|
||||
chartPeriods,
|
||||
setChartPeriod,
|
||||
reload,
|
||||
} = useTodaySummary(orgId);
|
||||
|
||||
const showNoSubscriptionNotice = useMemo(
|
||||
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
|
||||
@@ -69,6 +77,8 @@ export function TodayPage() {
|
||||
charts={data?.charts ?? {}}
|
||||
actions={data?.actions ?? {}}
|
||||
subscription={data?.subscription}
|
||||
chartPeriods={chartPeriods}
|
||||
onChartPeriodChange={setChartPeriod}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
hasError={Boolean(error)}
|
||||
|
||||
@@ -55,7 +55,8 @@ export function TodayPartnerCasesStackedBarChart({
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
interval={chartData.length <= 8 ? 0 : 'preserveStartEnd'}
|
||||
minTickGap={chartData.length > 8 ? 28 : undefined}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { apiClient } from './client';
|
||||
import type { TodaySummaryResponse } from '@/types/today';
|
||||
import type { TodayChartPeriods } from '@/components/today/chart-periods';
|
||||
|
||||
export interface TodaySummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
utcOffsetMinutes?: number;
|
||||
chartPeriods?: TodayChartPeriods;
|
||||
}
|
||||
|
||||
function chartPeriodQuery(periods?: TodayChartPeriods) {
|
||||
if (!periods) return {};
|
||||
return {
|
||||
appointmentsWeekPeriod: periods.appointmentsWeekAll,
|
||||
appointmentsWeekMinePeriod: periods.appointmentsWeekMine,
|
||||
labTaskActivityPeriod: periods.labTaskActivity,
|
||||
casesDuePeriod: periods.casesDue,
|
||||
treatmentMixPeriod: periods.treatmentMix,
|
||||
casePartnersPeriod: periods.casePartners,
|
||||
efficiencyPeriod: periods.efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
export const todayApi = {
|
||||
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
|
||||
const response = await apiClient.get('/today/summary', { params });
|
||||
const { chartPeriods, ...range } = params;
|
||||
const response = await apiClient.get('/today/summary', {
|
||||
params: { ...range, ...chartPeriodQuery(chartPeriods) },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
|
||||
import {
|
||||
loadTodayChartPeriods,
|
||||
saveTodayChartPeriods,
|
||||
type TodayChartPeriod,
|
||||
type TodayChartPeriodKey,
|
||||
type TodayChartPeriods,
|
||||
TODAY_CHART_PERIOD_DEFAULTS,
|
||||
} from '@/components/today/chart-periods';
|
||||
import { todayApi } from '@/lib/api/today';
|
||||
import type { TodaySummaryData } from '@/types/today';
|
||||
import type { ApiError } from '@/types/api';
|
||||
@@ -11,6 +19,8 @@ interface UseTodaySummaryResult {
|
||||
loading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
error: ApiError | null;
|
||||
chartPeriods: TodayChartPeriods;
|
||||
setChartPeriod: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -19,6 +29,17 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
const [data, setData] = useState<TodaySummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [chartPeriods, setChartPeriods] = useState<TodayChartPeriods>(() =>
|
||||
organizationId ? loadTodayChartPeriods(organizationId) : TODAY_CHART_PERIOD_DEFAULTS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setChartPeriods(TODAY_CHART_PERIOD_DEFAULTS);
|
||||
return;
|
||||
}
|
||||
setChartPeriods(loadTodayChartPeriods(organizationId));
|
||||
}, [organizationId]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!organizationId) {
|
||||
@@ -34,14 +55,18 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
try {
|
||||
const range = getLocalDayIsoRange(new Date());
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
|
||||
const response = await todayApi.summary({
|
||||
...range,
|
||||
utcOffsetMinutes,
|
||||
chartPeriods,
|
||||
});
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err as ApiError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [organizationId]);
|
||||
}, [organizationId, chartPeriods]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
@@ -54,11 +79,25 @@ export function useTodaySummary(organizationId?: string | null): UseTodaySummary
|
||||
void reload();
|
||||
}, [organizationId, reload]);
|
||||
|
||||
const setChartPeriod = useCallback(
|
||||
(key: TodayChartPeriodKey, period: TodayChartPeriod) => {
|
||||
setChartPeriods((prev) => {
|
||||
if (prev[key] === period) return prev;
|
||||
const next = { ...prev, [key]: period };
|
||||
if (organizationId) saveTodayChartPeriods(organizationId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[organizationId],
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad: loading && !data,
|
||||
error,
|
||||
chartPeriods,
|
||||
setChartPeriod,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user