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 ? ( +

{emptyMessage}

) : ( 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')}

+
+

{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, + }; }