mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 16:32:06 +00:00
feat(ui): add loading/error primitives and dialog guard
This commit is contained in:
parent
12e8cb50a4
commit
3c42abea40
10 changed files with 369 additions and 3 deletions
|
|
@ -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 (
|
||||||
|
<LoadingRegion
|
||||||
|
label={variant === "form" ? "Loading work order form" : "Loading work order"}
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
>
|
||||||
|
<Skeleton variant="text" width={200} height={20} animation="wave" />
|
||||||
|
<Skeleton variant="text" width={280} height={32} animation="wave" />
|
||||||
|
|
||||||
|
{variant === "view" ? (
|
||||||
|
<Stack spacing={3} className="mt-2">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Box key={i} className="rounded-lg border border-border p-4">
|
||||||
|
<Skeleton variant="text" width={120} height={18} className="mb-3" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3} className="mt-2">
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Skeleton variant="text" width={100} height={16} className="mb-2" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={56} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={56} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="flex-end" className="mt-4">
|
||||||
|
<Skeleton variant="rounded" width={96} height={36} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" width={96} height={36} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Box, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
|
||||||
|
export function DispatchDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<LoadingRegion label="Loading dispatch details">
|
||||||
|
<DialogTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton variant="text" width={200} height={28} animation="wave" />
|
||||||
|
<Skeleton variant="circular" width={28} height={28} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className="flex flex-col gap-6">
|
||||||
|
<Box className="rounded border border-border p-3">
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="flex-1 space-y-1">
|
||||||
|
<Skeleton variant="text" width={64} height={12} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={24} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={120} height={20} className="mb-2" animation="wave" />
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Skeleton variant="rounded" width={20} height={20} animation="wave" />
|
||||||
|
<Skeleton variant="text" width="50%" height={16} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={80} height={20} className="mb-2" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={100} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={100} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Skeleton variant="rounded" height={80} animation="wave" />
|
||||||
|
</DialogContent>
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<LoadingRegion label="Loading details" className="w-full">
|
||||||
|
{variant === "info" && <InfoSkeleton />}
|
||||||
|
{variant === "comments" && <CommentsSkeleton />}
|
||||||
|
{variant === "audit" && <AuditSkeleton />}
|
||||||
|
{(variant === "completion" || variant === "extras") && <DocsSkeleton />}
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
<Skeleton variant="text" width={72} height={12} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={36} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentsSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-1.5 rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton variant="circular" width={28} height={28} animation="wave" />
|
||||||
|
<Skeleton variant="text" width={120} height={14} animation="wave" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton variant="rounded" height={80} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="flex gap-3">
|
||||||
|
<Skeleton variant="circular" width={10} height={10} className="mt-1.5 shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Skeleton variant="text" width="60%" height={14} animation="wave" />
|
||||||
|
<Skeleton variant="text" width="40%" height={12} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Skeleton variant="text" width={140} height={16} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={48} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={120} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<LoadingRegion label="Loading work orders" className="h-full">
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: WO_TABLE_MIN_WIDTH,
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WoTableHead />
|
||||||
|
<tbody>
|
||||||
|
{days.map((day) => (
|
||||||
|
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DaySkeletonSection({ label, rows }: { label: string; rows: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={WO_TABLE_COLS.length}
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Skeleton variant="rounded" width={28} height={16} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<TableSkeletonRows rows={rows} columns={SKELETON_COLS} rowHeight={WO_TABLE_ROW_HEIGHT} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,13 +51,15 @@ export function Button({
|
||||||
<MuiButton
|
<MuiButton
|
||||||
{...mapped}
|
{...mapped}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
aria-disabled={disabled || loading || undefined}
|
||||||
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
|
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
|
||||||
className={cn(mapped.className, className)}
|
className={cn(mapped.className, className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} aria-hidden />
|
||||||
{loadingText}
|
{loadingText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
28
src/components/ui/loading-region.tsx
Normal file
28
src/components/ui/loading-region.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(className)}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
role={busy ? "status" : undefined}
|
||||||
|
aria-label={busy ? label : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/ui/query-error-panel.tsx
Normal file
42
src/components/ui/query-error-panel.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-2 rounded-lg border border-border bg-background",
|
||||||
|
compact ? "p-3" : "p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="error">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
onClick={onRetry}
|
||||||
|
sx={{ minHeight: 32, height: 32 }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { Drawer } from "@mui/material";
|
import { Drawer } from "@mui/material";
|
||||||
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -8,6 +8,8 @@ type SideSheetProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
widthClass?: string;
|
widthClass?: string;
|
||||||
|
/** When true, backdrop click and ESC do not close the sheet. */
|
||||||
|
closeDisabled?: boolean;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -16,16 +18,23 @@ export function SideSheet({
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
widthClass = "w-full max-w-sm",
|
widthClass = "w-full max-w-sm",
|
||||||
|
closeDisabled = false,
|
||||||
"aria-label": ariaLabel = "Side panel",
|
"aria-label": ariaLabel = "Side panel",
|
||||||
}: SideSheetProps) {
|
}: SideSheetProps) {
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={(_event, reason) => {
|
||||||
|
if (closeDisabled) return;
|
||||||
|
if (reason === "backdropClick" || reason === "escapeKeyDown") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
backdrop: {
|
backdrop: {
|
||||||
sx: DIALOG_BLUR_BACKDROP_SX,
|
sx: DIALOG_BLUR_BACKDROP_SX,
|
||||||
|
...(closeDisabled ? { onClick: (e: MouseEvent) => e.stopPropagation() } : {}),
|
||||||
},
|
},
|
||||||
paper: {
|
paper: {
|
||||||
className: cn(
|
className: cn(
|
||||||
|
|
|
||||||
29
src/components/ui/table-skeleton-rows.tsx
Normal file
29
src/components/ui/table-skeleton-rows.tsx
Normal file
|
|
@ -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) => (
|
||||||
|
<tr key={rowIndex} style={{ height: rowHeight }}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} style={{ padding: "0 14px", width: col.width, minWidth: col.width }}>
|
||||||
|
<Skeleton
|
||||||
|
variant="rounded"
|
||||||
|
height={14}
|
||||||
|
width="72%"
|
||||||
|
animation="wave"
|
||||||
|
sx={{ bgcolor: "action.hover" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/lib/dialog-guard.ts
Normal file
10
src/lib/dialog-guard.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue