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:
@@ -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…",
|
||||
|
||||
@@ -216,7 +216,10 @@
|
||||
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
||||
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
||||
"viewAllAppointments": "مشاهده برنامه",
|
||||
"noUpcomingAppointments": "نوبت پیشرویی برای باقی امروز وجود ندارد."
|
||||
"noUpcomingAppointments": "نوبت پیشرویی برای باقی امروز وجود ندارد.",
|
||||
"retryLoad": "تلاش مجدد",
|
||||
"sectionLoadError": "نمایش این بخش ممکن نشد.",
|
||||
"lastUpdated": "بهروزرسانی در {time}"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "در حال انتقال...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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">
|
||||
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
if (!organizationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
}, [organizationId, reload]);
|
||||
|
||||
return { data, loading, error, reload };
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad: loading && !data,
|
||||
error,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user