diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 70e08b6..f3127f3 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -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…",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 25ebe73..4836a03 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -216,7 +216,10 @@
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
"viewAllAppointments": "مشاهده برنامه",
- "noUpcomingAppointments": "نوبت پیشرویی برای باقی امروز وجود ندارد."
+ "noUpcomingAppointments": "نوبت پیشرویی برای باقی امروز وجود ندارد.",
+ "retryLoad": "تلاش مجدد",
+ "sectionLoadError": "نمایش این بخش ممکن نشد.",
+ "lastUpdated": "بهروزرسانی در {time}"
},
"staff": {
"redirecting": "در حال انتقال...",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index ba64cf1..a285a55 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -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...",
diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx
index 56817e3..192a324 100644
--- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx
+++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx
@@ -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 (
-
{t('welcomeBack')}
+
+
{t('welcomeBack')}
+ {data?.generatedAt && !isInitialLoad ? (
+
+ {t('lastUpdated', {
+ time: new Intl.DateTimeFormat(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(new Date(data.generatedAt)),
+ })}
+
+ ) : null}
+
{showNoSubscriptionNotice && (
@@ -33,18 +70,53 @@ export default function TodayPage() {
)}
{error ? (
-
-
- {formatApiErrorMessage(error, t('loadError'))}
-
-
+
void reload()}
+ isRetrying={loading && Boolean(data)}
+ />
) : null}
-
+ }
+ >
+
+
-
+ {(showUpcoming || showCharts) && (!error || data) ? (
+
+ {showUpcoming ? (
+ }
+ >
+
+
+ ) : null}
-
+ {showCharts ? (
+ }
+ >
+
+
+ ) : null}
+
+ ) : null}
);
}
diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/today/ChartCard.tsx
index 0f294d9..6d15d20 100644
--- a/frontend/src/components/today/ChartCard.tsx
+++ b/frontend/src/components/today/ChartCard.tsx
@@ -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
;
+ }
+
return (
@@ -27,10 +32,8 @@ export function ChartCard({
) : null}
- {loading ? (
-
- ) : isEmpty ? (
-
+ {isEmpty ? (
+
) : (
diff --git a/frontend/src/components/today/TodayChartsSection.tsx b/frontend/src/components/today/TodayChartsSection.tsx
index 07a8810..5ae13e2 100644
--- a/frontend/src/components/today/TodayChartsSection.tsx
+++ b/frontend/src/components/today/TodayChartsSection.tsx
@@ -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 (
+
+ {showTreatmentMix ? : null}
+ {showTasksByStep ? : null}
+
+ );
+ }
+
+ const chartCount = (showTreatmentMix ? 1 : 0) + (showTasksByStep ? 1 : 0);
+
return (
-
+
1 ? 'lg:grid-cols-2' : ''} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
+ >
{showTreatmentMix ? (
@@ -50,8 +70,7 @@ export function TodayChartsSection({ charts, loading = false }: TodayChartsSecti
diff --git a/frontend/src/components/today/TodayKpiGrid.tsx b/frontend/src/components/today/TodayKpiGrid.tsx
index 4f62192..09747b4 100644
--- a/frontend/src/components/today/TodayKpiGrid.tsx
+++ b/frontend/src/components/today/TodayKpiGrid.tsx
@@ -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 (
- {t('noWidgets')}
+
+ );
+ }
+
+ if (isInitialLoad) {
+ const skeletonCount = Math.max(getEligibleTodayKpis(currentOrganization).length, 4);
+ return (
+
+ {Array.from({ length: skeletonCount }, (_, index) => (
+
+ ))}
+
);
}
return (
-
+
{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}
/>
);
diff --git a/frontend/src/components/today/TodayLoadErrorBanner.tsx b/frontend/src/components/today/TodayLoadErrorBanner.tsx
new file mode 100644
index 0000000..d5f7090
--- /dev/null
+++ b/frontend/src/components/today/TodayLoadErrorBanner.tsx
@@ -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 (
+
+
{message}
+
+
+ );
+}
diff --git a/frontend/src/components/today/TodaySectionErrorFallback.tsx b/frontend/src/components/today/TodaySectionErrorFallback.tsx
new file mode 100644
index 0000000..5520e3e
--- /dev/null
+++ b/frontend/src/components/today/TodaySectionErrorFallback.tsx
@@ -0,0 +1,13 @@
+import { Card } from '@/components/ui/shared/Card';
+
+interface TodaySectionErrorFallbackProps {
+ message: string;
+}
+
+export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) {
+ return (
+
+ {message}
+
+ );
+}
diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/today/TodaySkeleton.tsx
new file mode 100644
index 0000000..3a15438
--- /dev/null
+++ b/frontend/src/components/today/TodaySkeleton.tsx
@@ -0,0 +1,36 @@
+interface SkeletonBlockProps {
+ className?: string;
+}
+
+export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
+ return (
+
+ );
+}
+
+export function KpiCardSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function ChartCardSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function ListRowSkeleton() {
+ return
;
+}
diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/today/TodayUpcomingAppointments.tsx
index 9aab5f4..be8d03f 100644
--- a/frontend/src/components/today/TodayUpcomingAppointments.tsx
+++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx
@@ -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 (
-
- {t('upcomingAppointmentsTitle')}
-
+
+
+
{[0, 1, 2].map((key) => (
-
+
))}
@@ -41,8 +47,8 @@ export function TodayUpcomingAppointments({
}
return (
-
-
+
+
{t('upcomingAppointmentsTitle')}
@@ -58,7 +64,9 @@ export function TodayUpcomingAppointments({
{appointments.length === 0 ? (
-
{t('noUpcomingAppointments')}
+
+
{t('noUpcomingAppointments')}
+
) : (
{appointments.map((appointment) => {
@@ -76,7 +84,7 @@ export function TodayUpcomingAppointments({
{appointment.patientName}
-
+
{timeLabel}
{appointment.purpose ? (
· {appointment.purpose}
diff --git a/frontend/src/components/today/TodayWidgetErrorBoundary.tsx b/frontend/src/components/today/TodayWidgetErrorBoundary.tsx
new file mode 100644
index 0000000..5bc0ec0
--- /dev/null
+++ b/frontend/src/components/today/TodayWidgetErrorBoundary.tsx
@@ -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;
+ }
+}
diff --git a/frontend/src/lib/hooks/useTodaySummary.ts b/frontend/src/lib/hooks/useTodaySummary.ts
index a711e61..1231dc2 100644
--- a/frontend/src/lib/hooks/useTodaySummary.ts
+++ b/frontend/src/lib/hooks/useTodaySummary.ts
@@ -9,17 +9,19 @@ import type { ApiError } from '@/types/api';
interface UseTodaySummaryResult {
data: TodaySummaryData | null;
loading: boolean;
+ isInitialLoad: boolean;
error: ApiError | null;
reload: () => Promise;
}
-export function useTodaySummary(enabled = true): UseTodaySummaryResult {
+export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult {
+ const enabled = Boolean(organizationId);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState(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,
+ };
}