mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge remote-tracking branch 'origin/dev' into codex/pr44-current-base
This commit is contained in:
commit
66cc892107
12 changed files with 444 additions and 4 deletions
|
|
@ -21,6 +21,11 @@ function isAuthLoginRequest(request: Request): boolean {
|
|||
return request.url.includes(API_PATHS.authentication.login);
|
||||
}
|
||||
|
||||
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
|
||||
export function shouldStripJsonContentType(body: unknown): boolean {
|
||||
return body instanceof FormData;
|
||||
}
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefix: resolveApiPrefixUrl(env.apiUrl),
|
||||
headers: {
|
||||
|
|
@ -29,11 +34,15 @@ export const api: KyInstance = ky.create({
|
|||
},
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
({ request, options }) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.headers.set("Authorization", token);
|
||||
}
|
||||
// Let the runtime set multipart boundary; default JSON Content-Type breaks FormData.
|
||||
if (shouldStripJsonContentType(options.body)) {
|
||||
request.headers.delete("Content-Type");
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||
|
|
@ -58,10 +67,18 @@ export async function apiPost<T>(url: string, body?: unknown, options?: Options)
|
|||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.patch(url, { ...options, json: body }).json<T>();
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} sx={{ 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>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/query-error-panel.tsx
Normal file
39
src/components/ui/query-error-panel.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<Text variant="error">{message}</Text>
|
||||
{Boolean(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;
|
||||
}
|
||||
60
src/test/api/api-formdata-content-type.test.ts
Normal file
60
src/test/api/api-formdata-content-type.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shouldStripJsonContentType } from "@/api/api";
|
||||
|
||||
describe("shouldStripJsonContentType", () => {
|
||||
it("returns true for FormData (multipart uploads)", () => {
|
||||
const form = new FormData();
|
||||
form.append("file", new File(["x"], "a.png", { type: "image/png" }));
|
||||
expect(shouldStripJsonContentType(form)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for JSON body / undefined", () => {
|
||||
expect(shouldStripJsonContentType(undefined)).toBe(false);
|
||||
expect(shouldStripJsonContentType(JSON.stringify({ a: 1 }))).toBe(false);
|
||||
expect(shouldStripJsonContentType({ a: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api.post FormData Content-Type", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("does not send Content-Type: application/json when body is FormData", async () => {
|
||||
let capturedContentType: string | null = null;
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(
|
||||
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||
);
|
||||
capturedContentType = headers.get("Content-Type");
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const { api } = await import("@/api/api");
|
||||
const form = new FormData();
|
||||
form.append("category", "Before");
|
||||
form.append("file", new File(["png"], "image.png", { type: "image/png" }));
|
||||
|
||||
await api.post("workorders/9/media", { body: form, throwHttpErrors: false });
|
||||
|
||||
expect(capturedContentType).not.toBe("application/json");
|
||||
// Browser sets multipart/form-data; boundary=... when Content-Type was stripped.
|
||||
// In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK
|
||||
// as long as it is not forced JSON.
|
||||
// Cast after expect(): Vitest assertion narrowing collapses string|null to never.
|
||||
const contentType = capturedContentType as string | null;
|
||||
if (contentType !== null) {
|
||||
expect(contentType.toLowerCase()).toContain("multipart/form-data");
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue