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