From 3c42abea40e9bd07d357ee33e86280a3fed0eb6e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:41:04 -0300 Subject: [PATCH] feat(ui): add loading/error primitives and dialog guard --- .../loading/detail-page-skeleton.tsx | 50 +++++++++++++ .../loading/dispatch-detail-skeleton.tsx | 49 ++++++++++++ .../loading/slide-over-tab-skeleton.tsx | 75 +++++++++++++++++++ .../_components/loading/wo-table-skeleton.tsx | 72 ++++++++++++++++++ src/components/ui/button.tsx | 4 +- src/components/ui/loading-region.tsx | 28 +++++++ src/components/ui/query-error-panel.tsx | 42 +++++++++++ src/components/ui/side-sheet.tsx | 13 +++- src/components/ui/table-skeleton-rows.tsx | 29 +++++++ src/lib/dialog-guard.ts | 10 +++ 10 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx create mode 100644 src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx create mode 100644 src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx create mode 100644 src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx create mode 100644 src/components/ui/loading-region.tsx create mode 100644 src/components/ui/query-error-panel.tsx create mode 100644 src/components/ui/table-skeleton-rows.tsx create mode 100644 src/lib/dialog-guard.ts diff --git a/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx new file mode 100644 index 00000000..a5c0671e --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx @@ -0,0 +1,50 @@ +import { Box, Skeleton, Stack } from "@mui/material"; +import { LoadingRegion } from "@/components/ui/loading-region"; + +type DetailPageSkeletonProps = { + /** Form layout (create/edit) vs view layout. */ + variant?: "view" | "form"; +}; + +export function DetailPageSkeleton({ variant = "view" }: DetailPageSkeletonProps) { + return ( + + + + + {variant === "view" ? ( + + {Array.from({ length: 3 }, (_, i) => ( + + + + + + + + + ))} + + ) : ( + + {Array.from({ length: 4 }, (_, i) => ( + + + + + + + + ))} + + + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx new file mode 100644 index 00000000..8a1d81c5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx @@ -0,0 +1,49 @@ +import { Box, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; +import { LoadingRegion } from "@/components/ui/loading-region"; + +export function DispatchDetailSkeleton() { + return ( + + +
+ + +
+
+ + + + {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+
+ + + + + {Array.from({ length: 3 }, (_, i) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx new file mode 100644 index 00000000..b7df1d07 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx @@ -0,0 +1,75 @@ +import { Skeleton, Stack } from "@mui/material"; +import { LoadingRegion } from "@/components/ui/loading-region"; + +export type SlideOverTabSkeletonVariant = "info" | "comments" | "audit" | "completion" | "extras"; + +type SlideOverTabSkeletonProps = { + variant?: SlideOverTabSkeletonVariant; +}; + +export function SlideOverTabSkeleton({ variant = "info" }: SlideOverTabSkeletonProps) { + return ( + + {variant === "info" && } + {variant === "comments" && } + {variant === "audit" && } + {(variant === "completion" || variant === "extras") && } + + ); +} + +function InfoSkeleton() { + return ( + + {Array.from({ length: 6 }, (_, i) => ( +
+ + +
+ ))} +
+ ); +} + +function CommentsSkeleton() { + return ( + + {Array.from({ length: 3 }, (_, i) => ( +
+
+ + +
+ +
+ ))} + +
+ ); +} + +function AuditSkeleton() { + return ( + + {Array.from({ length: 4 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function DocsSkeleton() { + return ( + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx new file mode 100644 index 00000000..85cee325 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx @@ -0,0 +1,72 @@ +import { Skeleton } from "@mui/material"; +import { LoadingRegion } from "@/components/ui/loading-region"; +import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows"; +import { buildWeekSkeleton } from "@/domain/work-orders/utils/table-day-utils"; +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { + WO_TABLE_COLS, + WO_TABLE_MIN_WIDTH, + WO_TABLE_ROW_HEIGHT, +} from "@/app/(protected)/workorders/_components/list/table/wo-table-cols"; + +type WoTableSkeletonProps = { + weekMonday: string; + rowsPerDay?: number; +}; + +const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w })); + +export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) { + const days = buildWeekSkeleton(weekMonday); + + return ( + + + + + {days.map((day) => ( + + ))} + +
+
+ ); +} + +function DaySkeletonSection({ label, rows }: { label: string; rows: number }) { + return ( + <> + + +
+ + {label} + + +
+ + + + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 9813b3f7..83c7e12d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -51,13 +51,15 @@ export function Button({ {loading ? ( <> - + {loadingText} ) : ( diff --git a/src/components/ui/loading-region.tsx b/src/components/ui/loading-region.tsx new file mode 100644 index 00000000..6a6db559 --- /dev/null +++ b/src/components/ui/loading-region.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type LoadingRegionProps = { + busy?: boolean; + label?: string; + className?: string; + children: ReactNode; +}; + +/** Accessible wrapper for loading/skeleton regions. */ +export function LoadingRegion({ + busy = true, + label = "Loading", + className, + children, +}: LoadingRegionProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/query-error-panel.tsx b/src/components/ui/query-error-panel.tsx new file mode 100644 index 00000000..7adceb95 --- /dev/null +++ b/src/components/ui/query-error-panel.tsx @@ -0,0 +1,42 @@ +import { Typography } from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type QueryErrorPanelProps = { + message?: string; + onRetry?: () => void; + className?: string; + compact?: boolean; +}; + +export function QueryErrorPanel({ + message = "Something went wrong. Please try again.", + onRetry, + className, + compact = false, +}: QueryErrorPanelProps) { + return ( +
+ + {message} + + {onRetry && ( + + )} +
+ ); +} diff --git a/src/components/ui/side-sheet.tsx b/src/components/ui/side-sheet.tsx index d49532eb..e0813a20 100644 --- a/src/components/ui/side-sheet.tsx +++ b/src/components/ui/side-sheet.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { MouseEvent, ReactNode } from "react"; import { Drawer } from "@mui/material"; import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; import { cn } from "@/lib/utils"; @@ -8,6 +8,8 @@ type SideSheetProps = { onClose: () => void; children: ReactNode; widthClass?: string; + /** When true, backdrop click and ESC do not close the sheet. */ + closeDisabled?: boolean; "aria-label"?: string; }; @@ -16,16 +18,23 @@ export function SideSheet({ onClose, children, widthClass = "w-full max-w-sm", + closeDisabled = false, "aria-label": ariaLabel = "Side panel", }: SideSheetProps) { return ( { + if (closeDisabled) return; + if (reason === "backdropClick" || reason === "escapeKeyDown") { + onClose(); + } + }} slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX, + ...(closeDisabled ? { onClick: (e: MouseEvent) => e.stopPropagation() } : {}), }, paper: { className: cn( diff --git a/src/components/ui/table-skeleton-rows.tsx b/src/components/ui/table-skeleton-rows.tsx new file mode 100644 index 00000000..3ae11b7c --- /dev/null +++ b/src/components/ui/table-skeleton-rows.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@mui/material"; + +type TableSkeletonRowsProps = { + rows?: number; + columns: { key: string; width: number }[]; + rowHeight?: number; +}; + +export function TableSkeletonRows({ rows = 3, columns, rowHeight = 52 }: TableSkeletonRowsProps) { + return ( + <> + {Array.from({ length: rows }, (_, rowIndex) => ( + + {columns.map((col) => ( + + + + ))} + + ))} + + ); +} diff --git a/src/lib/dialog-guard.ts b/src/lib/dialog-guard.ts new file mode 100644 index 00000000..c193be62 --- /dev/null +++ b/src/lib/dialog-guard.ts @@ -0,0 +1,10 @@ +/** + * Returns `undefined` as the dialog close handler while a mutation is pending, + * preventing backdrop click / ESC from dismissing the dialog. + */ +export function guardDialogClose( + isPending: boolean, + onClose: () => void, +): (() => void) | undefined { + return isPending ? undefined : onClose; +}