improvement: time period dropdown added to appropriate dashboard charts.

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

View File

@@ -5,25 +5,48 @@ import { ChartCardSkeleton } from '@/components/ui/today/TodaySkeleton';
interface ChartCardProps {
title: string;
subtitle?: string;
/** Compact control in the title row (e.g. period select) — does not grow card height. */
headerAction?: ReactNode;
children: ReactNode;
emptyMessage?: string;
isEmpty?: boolean;
loading?: boolean;
/**
* Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel).
* Chart column is independent and vertically centered.
* Three-column layout for chart + legend gadgets:
* 1) title + legends, 2) centered chart panel, 3) header action (period select).
* Column order is grid flow — RTL reverses naturally with `dir`.
*/
sidePanelLayout?: boolean;
chartPanel?: ReactNode;
}
function ChartCardHeader({
function ChartCardTitleBlock({
title,
subtitle,
}: Pick<ChartCardProps, 'title' | 'subtitle'>) {
return (
<div className="mb-3 shrink-0">
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
{title}
</h2>
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
</div>
);
}
function ChartCardHeader({
title,
subtitle,
headerAction,
}: Pick<ChartCardProps, 'title' | 'subtitle' | 'headerAction'>) {
return (
<div className="mb-3 shrink-0">
<div className="flex items-center justify-between gap-2">
<h2 className="min-w-0 truncate text-base font-semibold text-card-foreground">
{title}
</h2>
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
</div>
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
</div>
);
@@ -32,6 +55,7 @@ function ChartCardHeader({
export function ChartCard({
title,
subtitle,
headerAction,
children,
emptyMessage,
isEmpty = false,
@@ -45,9 +69,10 @@ export function ChartCard({
if (sidePanelLayout) {
return (
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
<Card className="grid h-full min-h-0 grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)_auto] gap-x-3 overflow-hidden">
{/* Col 1 — title + legends (unchanged stacking) */}
<div className="flex min-h-0 flex-col overflow-hidden">
<ChartCardHeader title={title} subtitle={subtitle} />
<ChartCardTitleBlock title={title} subtitle={subtitle} />
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
@@ -59,20 +84,28 @@ export function ChartCard({
)}
</div>
{/* Col 2 — pie / chart, centered */}
{!isEmpty && chartPanel ? (
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
<div className="aspect-square h-full max-h-full w-full max-w-full">
{chartPanel}
</div>
</div>
) : null}
) : (
<div className="min-h-0" aria-hidden />
)}
{/* Col 3 — period select at top, aligned to the outer end like other gadgets */}
<div className="flex shrink-0 flex-col items-end self-start pt-0.5">
{headerAction}
</div>
</Card>
);
}
return (
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
<ChartCardHeader title={title} subtitle={subtitle} />
<ChartCardHeader title={title} subtitle={subtitle} headerAction={headerAction} />
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">

View File

@@ -17,6 +17,7 @@ import {
TODAY_CHART_PRIMARY_COLOR,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
interface TodayAreaChartProps {
data: TodayChartBucket[];
@@ -31,10 +32,12 @@ export function TodayAreaChart({
gradientId = 'todayAreaFill',
showXAxis = true,
}: TodayAreaChartProps) {
const xAxis = todayChartDenseXAxisProps(data.length);
return (
<TodayChartFrame>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 2 : -4 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
@@ -45,10 +48,11 @@ export function TodayAreaChart({
{showXAxis ? (
<XAxis
dataKey="label"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={1}
interval={xAxis.interval}
minTickGap={xAxis.minTickGap}
/>
) : (
<XAxis dataKey="label" hide />
@@ -71,7 +75,7 @@ export function TodayAreaChart({
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
dot={{ r: 3, fill: color, strokeWidth: 0 }}
dot={data.length <= 14 ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={{ r: 5, fill: color }}
/>
</AreaChart>

View File

@@ -20,6 +20,7 @@ import {
TODAY_CHART_TOOLTIP_BG,
TODAY_CHART_TOOLTIP_BORDER,
} from '@/components/today/chart-theme';
import { todayChartCategoryXAxisProps } from '@/components/today/chart-day-labels';
interface TodayBarChartProps {
data: TodayChartBucket[];
@@ -28,9 +29,10 @@ interface TodayBarChartProps {
}
export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartProps) {
const xAxis = todayChartCategoryXAxisProps(data.length);
const chartData = data.map((item) => ({
...item,
shortLabel: truncateLabel(item.label),
shortLabel: truncateLabel(item.label, xAxis.truncateMax),
}));
return (
@@ -61,20 +63,21 @@ export function TodayBarChart({ data, colorForCode, onBarClick }: TodayBarChartP
<text
x={x}
y={y}
dy={16}
dy={14}
textAnchor="middle"
fill={fill}
fontSize={11}
fontSize={xAxis.fontSize}
>
{payload.value}
</text>
);
}
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }
}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
interval={xAxis.interval}
minTickGap={xAxis.minTickGap}
/>
<YAxis
allowDecimals={false}

View File

@@ -0,0 +1,51 @@
'use client';
import { useTranslations } from 'next-intl';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import type { TodayChartPeriod } from '@/components/today/chart-periods';
type TodayChartPeriodSelectProps = {
value: TodayChartPeriod;
options: readonly TodayChartPeriod[];
onChange: (period: TodayChartPeriod) => void;
disabled?: boolean;
id?: string;
};
export function TodayChartPeriodSelect({
value,
options,
onChange,
disabled = false,
id,
}: TodayChartPeriodSelectProps) {
const t = useTranslations('today');
const labelFor = (period: TodayChartPeriod) => {
switch (period) {
case 'month':
return t('periodMonth');
case 'year':
return t('periodYear');
default:
return t('periodWeek');
}
};
return (
<select
id={id}
value={value}
disabled={disabled}
aria-label={t('periodSelectAria')}
className={`${FORM_SELECT_CLASS} max-w-[7.5rem] shrink-0 py-0.5 text-xs`}
onChange={(event) => onChange(event.target.value as TodayChartPeriod)}
>
{options.map((period) => (
<option key={period} value={period}>
{labelFor(period)}
</option>
))}
</select>
);
}

View File

@@ -16,6 +16,13 @@ import {
} from '@/components/shared/permissions';
import { KpiCard } from '@/components/ui/today/KpiCard';
import { ChartCard } from '@/components/ui/today/ChartCard';
import { TodayChartPeriodSelect } from '@/components/ui/today/TodayChartPeriodSelect';
import {
allowedPeriodsForChart,
type TodayChartPeriod,
type TodayChartPeriodKey,
type TodayChartPeriods,
} from '@/components/today/chart-periods';
import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart';
import { TodayBarChart } from '@/components/ui/today/TodayBarChart';
import {
@@ -62,6 +69,8 @@ interface TodayDashboardProps {
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
subscription?: TodaySubscriptionSnapshot;
chartPeriods: TodayChartPeriods;
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
loading?: boolean;
isInitialLoad?: boolean;
hasError?: boolean;
@@ -72,6 +81,8 @@ export function TodayDashboard({
charts,
actions,
subscription,
chartPeriods,
onChartPeriodChange,
loading = false,
isInitialLoad = false,
hasError = false,
@@ -161,6 +172,8 @@ export function TodayDashboard({
charts,
actions,
subscription,
chartPeriods,
onChartPeriodChange,
kpiDefinitions,
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
showCaseCompletionCard:
@@ -195,6 +208,8 @@ export function TodayDashboard({
orgType,
isOwner,
charts,
chartPeriods,
onChartPeriodChange,
t,
dayLabelFormatter,
widgets,
@@ -314,6 +329,8 @@ function buildDashboardCells(options: {
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
subscription?: TodaySubscriptionSnapshot;
chartPeriods: TodayChartPeriods;
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
showSubscriptionCard: boolean;
showCaseCompletionCard: boolean;
@@ -348,6 +365,8 @@ function buildDashboardCells(options: {
canEditCases(options.currentOrganization))),
dayLabelFormatter: options.dayLabelFormatter,
prosthesisCatalog: options.prosthesisCatalog,
chartPeriods: options.chartPeriods,
onChartPeriodChange: options.onChartPeriodChange,
onTasksProsthesisClick: options.onTasksProsthesisClick,
onCasePartnerClick: options.onCasePartnerClick,
}),
@@ -438,10 +457,21 @@ function buildChartCells(options: {
showCasePartnersChart: boolean;
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
prosthesisCatalog: ProsthesisCatalogEntry[];
chartPeriods: TodayChartPeriods;
onChartPeriodChange: (key: TodayChartPeriodKey, period: TodayChartPeriod) => void;
onTasksProsthesisClick?: (code: string) => void;
onCasePartnerClick?: (code: string) => void;
}): TodayDashboardCell[] {
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart, chartPeriods } = options;
const periodSelect = (key: TodayChartPeriodKey) => (
<TodayChartPeriodSelect
value={chartPeriods[key]}
options={allowedPeriodsForChart(key)}
onChange={(period) => options.onChartPeriodChange(key, period)}
/>
);
const cells: TodayDashboardCell[] = [];
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
@@ -467,7 +497,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartAppointmentsWeekAllTitle')}
subtitle={t('chartAppointmentsWeekAllSubtitle')}
headerAction={periodSelect('appointmentsWeekAll')}
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
@@ -488,7 +518,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartAppointmentsWeekMineTitle')}
subtitle={t('chartAppointmentsWeekMineSubtitle')}
headerAction={periodSelect('appointmentsWeekMine')}
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
@@ -505,7 +535,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartLabTaskActivityTitle')}
subtitle={t('chartLabTaskActivitySubtitle')}
headerAction={periodSelect('labTaskActivity')}
isEmpty={labTaskActivityData.every(
(row) => row.completed === 0 && row.received === 0,
)}
@@ -533,11 +563,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartEfficiencyReportTitle')}
subtitle={
orgType === 'CLINIC'
? t('chartEfficiencyReportSubtitleClinic')
: t('chartEfficiencyReportSubtitleLab')
}
headerAction={periodSelect('efficiency')}
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
sidePanelLayout
@@ -588,7 +614,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartTreatmentMixTitle')}
subtitle={t('chartTreatmentMixSubtitle')}
headerAction={periodSelect('treatmentMix')}
isEmpty={treatmentData.length === 0}
emptyMessage={t('chartEmpty')}
>
@@ -638,7 +664,7 @@ function buildChartCells(options: {
content: (
<ChartCard
title={t('chartCasesDueWeekTitle')}
subtitle={t('chartCasesDueWeekSubtitle')}
headerAction={periodSelect('casesDue')}
isEmpty={casesDueWeekData.every((row) => row.count === 0)}
emptyMessage={t('chartEmpty')}
>
@@ -660,7 +686,7 @@ function buildChartCells(options: {
? t('chartCasePartnersClinicTitle')
: t('chartCasePartnersLabTitle')
}
subtitle={t('chartCasePartnersSubtitle')}
headerAction={periodSelect('casePartners')}
isEmpty={casePartnersData.every(
(row) => row.completed === 0 && row.pending === 0,
)}

View File

@@ -17,6 +17,7 @@ import {
TODAY_CHART_RECEIVED_COLOR,
TODAY_CHART_TOOLTIP_STYLE,
} from '@/components/today/chart-theme';
import { todayChartDenseXAxisProps } from '@/components/today/chart-day-labels';
import type { TodayStackedDayBucket } from '@/types/today';
export type LabTaskActivityChartRow = {
@@ -36,12 +37,15 @@ export function TodayLabTaskActivityChart({
completedLabel,
receivedLabel,
}: TodayLabTaskActivityChartProps) {
const xAxis = todayChartDenseXAxisProps(data.length);
const showDots = data.length <= 14;
return (
<TodayChartFrame>
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 2 }}>
<defs>
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
@@ -55,10 +59,11 @@ export function TodayLabTaskActivityChart({
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: xAxis.fontSize }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={1}
interval={xAxis.interval}
minTickGap={xAxis.minTickGap}
/>
<YAxis
allowDecimals={false}
@@ -79,7 +84,7 @@ export function TodayLabTaskActivityChart({
stroke={TODAY_CHART_COMPLETED_COLOR}
strokeWidth={2}
fill="url(#labTaskCompletedFill)"
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
dot={showDots ? { r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 } : false}
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
/>
<Area
@@ -89,7 +94,7 @@ export function TodayLabTaskActivityChart({
stroke={TODAY_CHART_RECEIVED_COLOR}
strokeWidth={2}
fill="url(#labTaskReceivedFill)"
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
dot={showDots ? { r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 } : false}
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
/>
</AreaChart>

View File

@@ -18,7 +18,15 @@ export function TodayPage() {
const tErrors = useTranslations('errors');
const { currentOrganization } = useAuth();
const orgId = currentOrganization?.id;
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
const {
data,
loading,
isInitialLoad,
error,
chartPeriods,
setChartPeriod,
reload,
} = useTodaySummary(orgId);
const showNoSubscriptionNotice = useMemo(
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
@@ -69,6 +77,8 @@ export function TodayPage() {
charts={data?.charts ?? {}}
actions={data?.actions ?? {}}
subscription={data?.subscription}
chartPeriods={chartPeriods}
onChartPeriodChange={setChartPeriod}
loading={loading}
isInitialLoad={isInitialLoad}
hasError={Boolean(error)}

View File

@@ -55,7 +55,8 @@ export function TodayPartnerCasesStackedBarChart({
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
tickLine={false}
interval={0}
interval={chartData.length <= 8 ? 0 : 'preserveStartEnd'}
minTickGap={chartData.length > 8 ? 28 : undefined}
/>
<YAxis
allowDecimals={false}