Merge remote-tracking branch 'origin/dev' into codex/pr44-current-base

This commit is contained in:
Alexandre Brandizzi 2026-07-24 18:52:29 -03:00
commit 66cc892107
12 changed files with 444 additions and 4 deletions

View file

@ -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>();
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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}
</>
) : (

View 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>
);
}

View 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>
);
}

View file

@ -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(

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

View 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");
}
});
});