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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 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 xl:grid-cols-2 gap-4">
|
||||
<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} />
|
||||
|
||||
@@ -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 (
|
||||
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
|
||||
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal file
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal file
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/today/TodaySkeleton.tsx
Normal file
36
frontend/src/components/today/TodaySkeleton.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal file
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user