Polish Today dashboard loading, errors, and layout (Phase 4).

Add section error boundaries, retry banner with stale-while-revalidate, shared skeletons, responsive action layout, and org-switch refetching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 00:53:40 +03:30
parent 3f2b332bd3
commit 2f8c9f0f4d
13 changed files with 312 additions and 45 deletions

View File

@@ -216,7 +216,10 @@
"upcomingAppointmentsTitle": "Upcoming Today",
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
"viewAllAppointments": "View schedule",
"noUpcomingAppointments": "No upcoming appointments for the rest of today."
"noUpcomingAppointments": "No upcoming appointments for the rest of today.",
"retryLoad": "Try again",
"sectionLoadError": "This section could not be displayed.",
"lastUpdated": "Updated at {time}"
},
"staff": {
"redirecting": "Redirecting…",

View File

@@ -216,7 +216,10 @@
"upcomingAppointmentsTitle": "نوبت‌های پیش رو",
"upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز",
"viewAllAppointments": "مشاهده برنامه",
"noUpcomingAppointments": "نوبت پیش‌رویی برای باقی امروز وجود ندارد."
"noUpcomingAppointments": "نوبت پیش‌رویی برای باقی امروز وجود ندارد.",
"retryLoad": "تلاش مجدد",
"sectionLoadError": "نمایش این بخش ممکن نشد.",
"lastUpdated": "به‌روزرسانی در {time}"
},
"staff": {
"redirecting": "در حال انتقال...",

View File

@@ -216,7 +216,10 @@
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
"viewAllAppointments": "Bekijk planning",
"noUpcomingAppointments": "Geen komende afspraken meer voor vandaag."
"noUpcomingAppointments": "Geen komende afspraken meer voor vandaag.",
"retryLoad": "Opnieuw proberen",
"sectionLoadError": "Dit onderdeel kon niet worden weergegeven.",
"lastUpdated": "Bijgewerkt om {time}"
},
"staff": {
"redirecting": "Bezig met doorsturen...",

View File

@@ -1,24 +1,61 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import {
canAccessAppointmentsSection,
canViewTasks,
canViewTreatment,
} from '@/components/shared/permissions';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
import { TodayChartsSection } from '@/components/today/TodayChartsSection';
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
export default function TodayPage() {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const { data, loading, error } = useTodaySummary(Boolean(currentOrganization?.id));
const orgId = currentOrganization?.id;
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
const showUpcoming = canAccessAppointmentsSection(currentOrganization);
const showCharts =
(currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization)) ||
(currentOrganization?.type === 'LAB' && canViewTasks(currentOrganization));
const actionLayoutClass = useMemo(() => {
if (showUpcoming && showCharts) {
return 'grid grid-cols-1 xl:grid-cols-2 gap-4 items-start';
}
return 'grid grid-cols-1 gap-4';
}, [showUpcoming, showCharts]);
const sectionErrorMessage = t('sectionLoadError');
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
{data?.generatedAt && !isInitialLoad ? (
<p className="text-xs text-text-muted">
{t('lastUpdated', {
time: new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(data.generatedAt)),
})}
</p>
) : null}
</div>
{showNoSubscriptionNotice && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
@@ -33,18 +70,53 @@ export default function TodayPage() {
)}
{error ? (
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4">
<p className="text-sm text-badge-danger-fg">
{formatApiErrorMessage(error, t('loadError'))}
</p>
</div>
<TodayLoadErrorBanner
message={formatApiErrorMessage(error, t('loadError'))}
retryLabel={t('retryLoad')}
onRetry={() => void reload()}
isRetrying={loading && Boolean(data)}
/>
) : null}
<TodayKpiGrid widgets={data?.widgets ?? {}} loading={loading} />
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayKpiGrid
widgets={data?.widgets ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
hasError={Boolean(error)}
/>
</TodayWidgetErrorBoundary>
<TodayUpcomingAppointments actions={data?.actions ?? {}} loading={loading} />
{(showUpcoming || showCharts) && (!error || data) ? (
<div className={actionLayoutClass}>
{showUpcoming ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayUpcomingAppointments
actions={data?.actions ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
/>
</TodayWidgetErrorBoundary>
) : null}
<TodayChartsSection charts={data?.charts ?? {}} loading={loading} />
{showCharts ? (
<TodayWidgetErrorBoundary
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
>
<TodayChartsSection
charts={data?.charts ?? {}}
loading={loading}
isInitialLoad={isInitialLoad}
className={showUpcoming ? '' : 'max-w-none'}
/>
</TodayWidgetErrorBoundary>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { Card } from '@/components/ui/shared/Card';
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
interface ChartCardProps {
title: string;
@@ -18,6 +19,10 @@ export function ChartCard({
isEmpty = false,
loading = false,
}: ChartCardProps) {
if (loading) {
return <ChartCardSkeleton />;
}
return (
<Card className="min-h-[280px] flex flex-col">
<div className="mb-4">
@@ -27,10 +32,8 @@ export function ChartCard({
) : null}
</div>
{loading ? (
<div className="flex-1 min-h-[220px] animate-pulse rounded-[var(--radius-md)] bg-background-secondary/60" />
) : isEmpty ? (
<div className="flex-1 min-h-[220px] flex items-center justify-center">
{isEmpty ? (
<div className="flex-1 min-h-[220px] flex items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
<p className="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
</div>
) : (

View File

@@ -8,14 +8,22 @@ import {
} from '@/components/shared/permissions';
import { ChartCard } from '@/components/today/ChartCard';
import { TodayBarChart } from '@/components/today/TodayBarChart';
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
import type { TodaySummaryCharts } from '@/types/today';
interface TodayChartsSectionProps {
charts: TodaySummaryCharts;
loading?: boolean;
isInitialLoad?: boolean;
className?: string;
}
export function TodayChartsSection({ charts, loading = false }: TodayChartsSectionProps) {
export function TodayChartsSection({
charts,
loading = false,
isInitialLoad = false,
className = '',
}: TodayChartsSectionProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const orgType = currentOrganization?.type;
@@ -32,14 +40,26 @@ export function TodayChartsSection({ charts, loading = false }: TodayChartsSecti
const treatmentData = charts.treatmentMixWeek ?? [];
const tasksData = charts.tasksByWorkflowStep ?? [];
if (isInitialLoad) {
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className={`grid grid-cols-1 gap-4 ${className}`}>
{showTreatmentMix ? <ChartCardSkeleton /> : null}
{showTasksByStep ? <ChartCardSkeleton /> : null}
</div>
);
}
const chartCount = (showTreatmentMix ? 1 : 0) + (showTasksByStep ? 1 : 0);
return (
<div
className={`grid grid-cols-1 ${chartCount > 1 ? 'lg:grid-cols-2' : ''} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
>
{showTreatmentMix ? (
<ChartCard
title={t('chartTreatmentMixTitle')}
subtitle={t('chartTreatmentMixSubtitle')}
loading={loading}
isEmpty={!loading && treatmentData.length === 0}
isEmpty={treatmentData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart data={treatmentData} />
@@ -50,8 +70,7 @@ export function TodayChartsSection({ charts, loading = false }: TodayChartsSecti
<ChartCard
title={t('chartTasksByStepTitle')}
subtitle={t('chartTasksByStepSubtitle')}
loading={loading}
isEmpty={!loading && tasksData.length === 0}
isEmpty={tasksData.length === 0}
emptyMessage={t('chartEmpty')}
>
<TodayBarChart data={tasksData} />

View File

@@ -3,29 +3,56 @@
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/hooks/useAuth';
import { KpiCard } from '@/components/today/KpiCard';
import { KpiCardSkeleton } from '@/components/today/TodaySkeleton';
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
import type { TodaySummaryWidgets } from '@/types/today';
interface TodayKpiGridProps {
widgets: TodaySummaryWidgets;
loading?: boolean;
isInitialLoad?: boolean;
hasError?: boolean;
}
export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) {
export function TodayKpiGrid({
widgets,
loading = false,
isInitialLoad = false,
hasError = false,
}: TodayKpiGridProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const definitions = loading
const definitions = isInitialLoad
? getEligibleTodayKpis(currentOrganization)
: getVisibleTodayKpis(currentOrganization, widgets);
if (!loading && definitions.length === 0) {
if (hasError && !loading && definitions.length === 0) {
return null;
}
if (!loading && !hasError && definitions.length === 0) {
return (
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
</div>
);
}
if (isInitialLoad) {
const skeletonCount = Math.max(getEligibleTodayKpis(currentOrganization).length, 4);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{Array.from({ length: skeletonCount }, (_, index) => (
<KpiCardSkeleton key={index} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div
className={`grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 ${loading ? 'opacity-70 transition-opacity' : ''}`}
>
{definitions.map((definition) => {
const value = definition.formatValue(widgets) ?? '—';
const subtitleKey = definition.formatSubtitle?.(widgets);
@@ -42,7 +69,6 @@ export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) {
subtitle={subtitle}
icon={definition.icon}
color={definition.color}
loading={loading}
href={definition.href}
/>
);

View File

@@ -0,0 +1,33 @@
'use client';
import { Button } from '@/components/ui/shared/Button';
interface TodayLoadErrorBannerProps {
message: string;
retryLabel: string;
onRetry: () => void;
isRetrying?: boolean;
}
export function TodayLoadErrorBanner({
message,
retryLabel,
onRetry,
isRetrying = false,
}: TodayLoadErrorBannerProps) {
return (
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p className="text-sm text-badge-danger-fg">{message}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRetry}
isLoading={isRetrying}
className="shrink-0 border-badge-danger-border text-badge-danger-fg hover:bg-badge-danger-bg/30"
>
{retryLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Card } from '@/components/ui/shared/Card';
interface TodaySectionErrorFallbackProps {
message: string;
}
export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) {
return (
<Card className="min-h-[120px] flex items-center justify-center border-badge-danger-border/40 bg-badge-danger-bg/20">
<p className="text-sm text-badge-danger-fg text-center px-4">{message}</p>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
interface SkeletonBlockProps {
className?: string;
}
export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
return (
<div
className={`animate-pulse rounded-[var(--radius-md)] bg-background-secondary/60 ${className}`}
aria-hidden
/>
);
}
export function KpiCardSkeleton() {
return (
<div className="rounded-[var(--radius-lg)] border border-card-border bg-card p-4">
<SkeletonBlock className="h-4 w-2/3" />
<SkeletonBlock className="h-8 w-16 mt-3" />
<SkeletonBlock className="h-3 w-1/3 mt-2" />
</div>
);
}
export function ChartCardSkeleton() {
return (
<div className="rounded-[var(--radius-lg)] border border-card-border bg-card p-4 min-h-[280px] flex flex-col">
<SkeletonBlock className="h-4 w-1/3" />
<SkeletonBlock className="h-3 w-1/4 mt-2" />
<SkeletonBlock className="flex-1 min-h-[220px] mt-4" />
</div>
);
}
export function ListRowSkeleton() {
return <SkeletonBlock className="h-12 w-full" />;
}

View File

@@ -7,16 +7,19 @@ import { Card } from '@/components/ui/shared/Card';
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
import { canAccessAppointmentsSection } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
import type { TodaySummaryActions } from '@/types/today';
interface TodayUpcomingAppointmentsProps {
actions: TodaySummaryActions;
loading?: boolean;
isInitialLoad?: boolean;
}
export function TodayUpcomingAppointments({
actions,
loading = false,
isInitialLoad = false,
}: TodayUpcomingAppointmentsProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
@@ -27,13 +30,16 @@ export function TodayUpcomingAppointments({
const appointments = actions.upcomingAppointmentsToday ?? [];
if (loading) {
if (isInitialLoad) {
return (
<Card>
<h2 className="text-base font-semibold text-card-foreground">{t('upcomingAppointmentsTitle')}</h2>
<div className="mt-4 space-y-3">
<Card className="min-h-[280px]">
<div className="mb-4 space-y-2">
<div className="h-4 w-40 animate-pulse rounded bg-background-secondary/60" />
<div className="h-3 w-56 animate-pulse rounded bg-background-secondary/60" />
</div>
<div className="space-y-3">
{[0, 1, 2].map((key) => (
<div key={key} className="h-12 animate-pulse rounded bg-background-secondary/60" />
<ListRowSkeleton key={key} />
))}
</div>
</Card>
@@ -41,8 +47,8 @@ export function TodayUpcomingAppointments({
}
return (
<Card>
<div className="flex items-center justify-between gap-3 mb-4">
<Card className={`min-h-[280px] ${loading ? 'opacity-70 transition-opacity' : ''}`}>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
<div>
<h2 className="text-base font-semibold text-card-foreground">
{t('upcomingAppointmentsTitle')}
@@ -58,7 +64,9 @@ export function TodayUpcomingAppointments({
</div>
{appointments.length === 0 ? (
<p className="text-sm text-text-muted">{t('noUpcomingAppointments')}</p>
<div className="flex flex-1 min-h-[160px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-4">
<p className="text-sm text-text-muted text-center">{t('noUpcomingAppointments')}</p>
</div>
) : (
<ul className="divide-y divide-border/40">
{appointments.map((appointment) => {
@@ -76,7 +84,7 @@ export function TodayUpcomingAppointments({
<p className="text-sm font-medium text-text-primary truncate">
{appointment.patientName}
</p>
<p className="text-xs text-text-muted mt-0.5">
<p className="text-xs text-text-muted mt-0.5 truncate">
{timeLabel}
{appointment.purpose ? (
<span className="text-text-secondary"> · {appointment.purpose}</span>

View File

@@ -0,0 +1,34 @@
'use client';
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface TodayWidgetErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}
interface TodayWidgetErrorBoundaryState {
hasError: boolean;
}
export class TodayWidgetErrorBoundary extends Component<
TodayWidgetErrorBoundaryProps,
TodayWidgetErrorBoundaryState
> {
state: TodayWidgetErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): TodayWidgetErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Today widget render error:', error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

View File

@@ -9,17 +9,19 @@ import type { ApiError } from '@/types/api';
interface UseTodaySummaryResult {
data: TodaySummaryData | null;
loading: boolean;
isInitialLoad: boolean;
error: ApiError | null;
reload: () => Promise<void>;
}
export function useTodaySummary(enabled = true): UseTodaySummaryResult {
export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult {
const enabled = Boolean(organizationId);
const [data, setData] = useState<TodaySummaryData | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<ApiError | null>(null);
const reload = useCallback(async () => {
if (!enabled) {
if (!organizationId) {
setData(null);
setLoading(false);
setError(null);
@@ -34,16 +36,28 @@ export function useTodaySummary(enabled = true): UseTodaySummaryResult {
const response = await todayApi.summary(range);
setData(response.data);
} catch (err) {
setData(null);
setError(err as ApiError);
} finally {
setLoading(false);
}
}, [enabled]);
}, [organizationId]);
useEffect(() => {
void reload();
}, [reload]);
return { data, loading, error, reload };
setData(null);
setError(null);
if (!organizationId) {
setLoading(false);
return;
}
setLoading(true);
void reload();
}, [organizationId, reload]);
return {
data,
loading,
isInitialLoad: loading && !data,
error,
reload,
};
}