fix(work-orders): preserve slide-over draft and gate media close

This commit is contained in:
Arthur Bassi 2026-07-22 11:37:16 -03:00 • committed by Arthur Bassi
parent 0682b47678
commit 475862003c
14 changed files with 929 additions and 37 deletions

View file

@ -0,0 +1,19 @@
import type { ElementType } from "react";
type CompDocCoLineProps = {
icon: ElementType;
text: string;
bold?: boolean;
};
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
return (
<div
className="flex items-center justify-end gap-1.5 break-words"
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
>
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
</div>
);
}

View file

@ -0,0 +1,39 @@
import type { ElementType } from "react";
type CompDocDetailRowProps = {
icon: ElementType;
label: string;
value: string;
last?: boolean;
};
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
return (
<div
className="flex items-start gap-2.5"
style={{
padding: "10px 0",
borderBottom: last ? "none" : "1px solid var(--color-border)",
}}
>
<Icon
size={15}
className="shrink-0"
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
/>
<div className="min-w-0">
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--color-text-primary)",
marginTop: 1,
}}
>
{value}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,292 @@
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
import { CompDocCoLine } from "./comp-doc-co-line";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreview({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
const proc = procedureFor(row.pm);
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{isGenerated ? (
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
) : (
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
</span>
</div>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<div
className="flex flex-wrap items-start justify-between gap-y-3"
style={{ padding: "20px 0 12px" }}
>
<div className="flex items-center">
<img
src="/seahaven-logo.svg"
alt="Seahaven Industries"
style={{ height: 34, width: "auto", display: "block" }}
/>
</div>
<div className="space-y-1">
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
</div>
</div>
<div
style={{
height: 3,
borderRadius: 2,
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
}}
/>
{isGenerated && (
<div
className="inline-flex items-center gap-1.5"
style={{
marginTop: 16,
padding: "4px 10px",
borderRadius: 9999,
background: "var(--doc-yes-bg)",
color: "var(--color-success)",
fontSize: 11.5,
fontWeight: 600,
}}
>
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
</div>
)}
<Text
as="h2"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
as="p"
className="font-mono"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
as="p"
style={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{Boolean(notes) && (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{isNotRequired && (
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
<div
className="inline-flex items-center gap-2"
style={{
marginTop: "30%",
transform: "rotate(-15deg)",
border: "3px solid var(--warning)",
color: "var(--warning)",
borderRadius: 8,
padding: "8px 18px",
fontWeight: 800,
fontSize: 22,
letterSpacing: "0.06em",
background: "rgba(255,255,255,0.65)",
}}
>
<Ban size={20} /> NOT REQUIRED
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocProcedureListProps = {
proc: Procedure;
};
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{st.sub != null && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{st.sub.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
))}
</ol>
)}
</li>
))}
</ol>
);
}

View file

@ -0,0 +1,207 @@
import type { CSSProperties } from "react";
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
const COMP_DOC_INPUT_STYLE: CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};
type CompDocSidebarProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
docState: DocState;
generatedAt: string;
notes: string;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
onNotesChange: (value: string) => void;
onDateCompletedChange: (value: string) => void;
};
export function CompDocSidebar({
row,
site,
docState,
generatedAt,
notes,
editDateCompleted,
amazonRep,
seaRep,
isGenerated,
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
const cfg = DOC_STATE_CFG[docState];
const StateIcon = cfg.icon;
const editRow = (
label: string,
value: string,
onChange: (v: string) => void,
type = "text",
placeholder = "",
) => (
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
{label}
</div>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={isGenerated}
placeholder={placeholder}
className={COMP_DOC_INPUT_CLS}
style={{
...COMP_DOC_INPUT_STYLE,
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
);
return (
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
<div
className="flex items-start gap-3"
style={{
padding: 14,
borderRadius: 10,
background: cfg.cardBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<div
className="flex shrink-0 items-center justify-center"
style={{
width: 34,
height: 34,
borderRadius: 8,
background: cfg.cardIconBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<StateIcon size={17} style={{ color: cfg.iconColor }} />
</div>
<div className="min-w-0">
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{cfg.cardTitle}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
color: "var(--muted-foreground)",
marginTop: 3,
}}
>
{docStatusCardDescription(docState, generatedAt)}
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
{editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
</div>
</div>
);
}

View file

@ -48,7 +48,12 @@ export function AuditTimeline({
)}
</Typography>
{Boolean(entry.action && entry.fieldName) && (
<Typography variant="caption" color="text.secondary" display="block" className="mt-1">
<Typography
variant="caption"
color="text.secondary"
className="mt-1"
sx={{ display: "block" }}
>
{entry.fieldName}
{entry.oldValue || entry.newValue
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`

View file

@ -204,7 +204,7 @@ export function SlideOverCompletionTab({
</div>
)}
{Boolean(completion && completion.dispatchSignoffs.length > 0) && (
{completion != null && completion.dispatchSignoffs.length > 0 && (
<div className="space-y-1 pt-2">
<span
style={{

View file

@ -86,7 +86,7 @@ export function WorkOrderDetailsSection({
<DetailField label="Priority" value={wo.priority || "—"} />
<DetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
Assigned To
</Typography>
<WorkOrderAssigneeMenu
@ -103,7 +103,7 @@ export function WorkOrderDetailsSection({
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Classification</Typography>
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<DetailFieldGrid>
@ -116,7 +116,7 @@ export function WorkOrderDetailsSection({
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Schedule</Typography>
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
@ -133,7 +133,7 @@ export function WorkOrderDetailsSection({
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Financial</Typography>
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -149,7 +149,7 @@ export function WorkOrderDetailsSection({
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
@ -207,7 +207,7 @@ export function WorkOrderDetailsSection({
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Description</Typography>
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>

View file

@ -33,7 +33,9 @@ import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { useAuthContext } from "@/providers/auth-context";
type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
@ -46,6 +48,11 @@ const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "extras", label: "Extra Docs" },
];
export type SlideOverSaveOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
@ -54,7 +61,7 @@ type WorkOrderSlideOverProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch) => void;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
onCancelWO: (row: WorkOrderTableRow) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -96,6 +103,8 @@ export function WorkOrderSlideOver({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
const [mediaBatchPending, setMediaBatchPending] = useState(false);
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
@ -147,7 +156,8 @@ export function WorkOrderSlideOver({
[detailForRow?.media],
);
const mediaPending = uploadMediaMutation.isPending || deleteMediaMutation.isPending;
const mediaPending =
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
@ -155,14 +165,28 @@ export function WorkOrderSlideOver({
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
};
const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => {
await runSequentialUploads(
files,
(file) => uploadMediaMutation.mutateAsync({ file, category }),
setMediaBatchPending,
);
};
const commit = () => {
if (!row || !activeDraft || !infoSource) return;
const patch = buildSlideOverPatch(activeDraft, infoSource);
if (Object.keys(patch).length > 0) {
onSave(row.id, patch);
if (Object.keys(patch).length === 0) {
setEditing(false);
setShowUnsaved(false);
return;
}
setEditing(false);
setShowUnsaved(false);
onSave(row.id, patch, {
onSuccess: () => {
setEditing(false);
setShowUnsaved(false);
},
});
};
const discard = () => {
@ -307,9 +331,7 @@ export function WorkOrderSlideOver({
mediaPending={mediaPending}
completionPending={completionPending}
onUploadMedia={(files, category) => {
for (const file of files) {
uploadMediaMutation.mutate({ file, category });
}
void uploadMediaFiles(files, category);
}}
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
onUploadCompletionPdf={(file) => {
@ -341,9 +363,7 @@ export function WorkOrderSlideOver({
isReadOnly={isDocumentsReadOnly}
pending={mediaPending}
onUpload={(files) => {
for (const file of files) {
uploadMediaMutation.mutate({ file, category: "Extra" });
}
void uploadMediaFiles(files, "Extra");
}}
onRemove={(id) => deleteMediaMutation.mutate(id)}
/>

View file

@ -1,8 +1,18 @@
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
@ -13,25 +23,29 @@ import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations({
const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
@ -55,11 +69,41 @@ export default function WorkOrdersListPage() {
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const activeSlideOverRow = useMemo(() => {
if (!slideOverRow) return null;
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
}, [slideOverRow, tableData.rows]);
const activeDocRow = useMemo(() => {
if (!docRow) return null;
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
}, [docRow, tableData.rows]);
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
tableData.setSelectedId(null);
};
const handleConfirmCancel = () => {
if (!confirmCancel) return;
patchField(confirmCancel.id, { status: "Canceled" });
setConfirmCancel(null);
handleCloseSlideOver();
};
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
@ -124,16 +168,16 @@ export default function WorkOrdersListPage() {
pmTypes={pmTypes}
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={(row, edit) => {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
onOpenSlideOver={handleOpenSlideOver}
onOpenCompDoc={setDocRow}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={patchField}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
@ -146,7 +190,51 @@ export default function WorkOrdersListPage() {
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
<WorkOrderSlideOver
row={activeSlideOverRow}
editMode={slideOverEdit}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => patchField(id, patch, options)}
onOpenCompletionDoc={setDocRow}
onCancelWO={setConfirmCancel}
onDuplicateFound={(existing) => handleOpenSlideOver(existing, false)}
saving={isPatchPending}
onClose={handleCloseSlideOver}
/>
<Dialog
open={Boolean(confirmCancel)}
onClose={cancelDialogClose}
slotProps={dialogBlurBackdropSlotProps}
>
<DialogTitle>Cancel work order?</DialogTitle>
<DialogContent>
<DialogContentText>
Cancel WO #{confirmCancel?.woNumber}? The record will be kept with status Canceled.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant="outline"
onClick={() => setConfirmCancel(null)}
disabled={isPatchPending}
>
Keep WO
</Button>
<Button
variant="destructive"
onClick={handleConfirmCancel}
loading={isPatchPending}
loadingText="Canceling…"
>
Cancel WO
</Button>
</DialogActions>
</Dialog>
{activeMediaRow != null && (
<MediaUploadModal
@ -155,11 +243,23 @@ export default function WorkOrdersListPage() {
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={(open) => {
if (!open) setDocRow(null);
}}
data={completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => {
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
patchField(activeDocRow.id, { docStatus: status });
}}
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
/>
)}
</Box>

View file

@ -123,8 +123,21 @@ export function useWorkOrderTableMutations(
},
});
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
patchMutation.mutate({ id, patch });
const patchField = (
id: string | number,
patch: WorkOrderTablePatch,
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
},
) => {
patchMutation.mutate(
{ id, patch },
{
onSuccess: () => options?.onSuccess?.(),
onError: (error) => options?.onError?.(error),
},
);
};
return {

View file

@ -0,0 +1,24 @@
/**
* Runs file uploads one-by-one while holding an aggregate pending flag true
* until every file has settled (success or failure). Avoids relying on a single
* TanStack Mutation `isPending`, which can flip false when a later small file
* finishes before an earlier large one.
*/
export async function runSequentialUploads<TFile>(
files: readonly TFile[],
upload: (file: TFile) => Promise<unknown>,
setPending: (pending: boolean) => void,
): Promise<void> {
if (files.length === 0) {
return;
}
setPending(true);
try {
for (const file of files) {
await upload(file);
}
} finally {
setPending(false);
}
}

View file

@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
describe("runSequentialUploads", () => {
it("keeps pending true across the full batch; second upload starts only after the first settles", async () => {
const pendingLog: boolean[] = [];
const setPending = (pending: boolean) => {
pendingLog.push(pending);
};
let resolveFirst: (() => void) | undefined;
let resolveSecond: (() => void) | undefined;
const first = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const second = new Promise<void>((resolve) => {
resolveSecond = resolve;
});
const upload = vi
.fn()
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
const done = runSequentialUploads(["large.bin", "small.bin"], upload, setPending);
expect(pendingLog).toEqual([true]);
expect(upload).toHaveBeenCalledTimes(1);
expect(upload).toHaveBeenCalledWith("large.bin");
// First file still in flight — pending must stay true.
await Promise.resolve();
expect(pendingLog).toEqual([true]);
expect(upload).toHaveBeenCalledTimes(1);
resolveFirst?.();
await Promise.resolve();
await Promise.resolve();
expect(upload).toHaveBeenCalledTimes(2);
expect(upload).toHaveBeenNthCalledWith(2, "small.bin");
expect(pendingLog).toEqual([true]);
resolveSecond?.();
await done;
expect(pendingLog).toEqual([true, false]);
});
it("clears pending when a middle upload rejects", async () => {
const setPending = vi.fn();
const upload = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("upload failed"));
await expect(runSequentialUploads(["a", "b", "c"], upload, setPending)).rejects.toThrow(
"upload failed",
);
expect(setPending.mock.calls.map((call) => call[0])).toEqual([true, false]);
expect(upload).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
/**
* Mirrors WorkOrderSlideOver commit: exit edit mode only after a successful save
* (or when there is nothing to persist).
*/
function commitSlideOverEdit(input: {
patchKeys: number;
onSave: (options: { onSuccess?: () => void }) => void;
exitEdit: () => void;
clearUnsaved: () => void;
}) {
if (input.patchKeys === 0) {
input.exitEdit();
input.clearUnsaved();
return;
}
input.onSave({
onSuccess: () => {
input.exitEdit();
input.clearUnsaved();
},
});
}
describe("slide-over commit save contract", () => {
it("exits edit mode immediately when there is no patch", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
const onSave = vi.fn();
commitSlideOverEdit({ patchKeys: 0, onSave, exitEdit, clearUnsaved });
expect(onSave).not.toHaveBeenCalled();
expect(exitEdit).toHaveBeenCalledOnce();
expect(clearUnsaved).toHaveBeenCalledOnce();
});
it("keeps edit mode when save fails (onSuccess never runs)", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
commitSlideOverEdit({
patchKeys: 1,
onSave: () => {
/* rejected — do not call onSuccess */
},
exitEdit,
clearUnsaved,
});
expect(exitEdit).not.toHaveBeenCalled();
expect(clearUnsaved).not.toHaveBeenCalled();
});
it("exits edit mode only from onSuccess after a rejected-then-retry path would not", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
let success: (() => void) | undefined;
commitSlideOverEdit({
patchKeys: 2,
onSave: (options) => {
success = options.onSuccess;
},
exitEdit,
clearUnsaved,
});
expect(exitEdit).not.toHaveBeenCalled();
success?.();
expect(exitEdit).toHaveBeenCalledOnce();
expect(clearUnsaved).toHaveBeenCalledOnce();
});
});