mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +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
|
||||
{...mapped}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
aria-disabled={disabled || loading || undefined}
|
||||
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
|
||||
className={cn(mapped.className, className)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} aria-hidden />
|
||||
{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 { 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 (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={(_event, reason) => {
|
||||
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(
|
||||
|
|
|
|||
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