fix(work-orders): persist completion doc fields without race

Map docStatus through the real update payload, serialize concurrent
table patches per work order, and split CompDoc UI for governance.
This commit is contained in:
Arthur Bassi 2026-07-28 12:49:51 -03:00
parent 725efd2703
commit b7056f569e
33 changed files with 1360 additions and 737 deletions

View file

@ -164,11 +164,12 @@ function maintainabilityGate(files) {
if (files.length === 0) {
return { skipped: true, reason: "no changed governed TS/TSX files" };
}
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
const result = spawnSync(
eslintBin,
process.execPath,
[
eslintJs,
...files,
...ruleArgs,
"--max-warnings=0",

View file

@ -0,0 +1,65 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditableRow } from "./comp-doc-editable-row";
type CompDocDetailsSectionProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
editDateCompleted: string;
onDateCompletedChange: (value: string) => void;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
};
export function CompDocDetailsSection({
row,
site,
editDateCompleted,
onDateCompletedChange,
amazonRep,
seaRep,
isGenerated,
}: CompDocDetailsSectionProps) {
return (
<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} />
<CompDocEditableRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
disabled={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
import { Ban, Save, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterDraftProps = {
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraft({
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,35 @@
import { CheckCircle2, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterGeneratedProps = {
generatedAt: string;
onDownloadPdf: () => void;
};
export function CompDocDialogFooterGenerated({
generatedAt,
onDownloadPdf,
}: CompDocDialogFooterGeneratedProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} /> Generated {generatedAt}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,47 @@
import { Ban, Sparkles, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterNotRequiredProps = {
onUndo: () => void;
};
export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onUndo}
>
<Undo2 size={14} /> Undo
</Button>
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,47 @@
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
type CompDocDialogFooterProps = {
docState: DocState;
generatedAt: string;
onMarkNotRequired: () => void;
onUndo: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
onDownloadPdf: () => void;
};
export function CompDocDialogFooter({
docState,
generatedAt,
onMarkNotRequired,
onUndo,
onSaveDraft,
onGenerate,
onDownloadPdf,
}: CompDocDialogFooterProps) {
return (
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
{docState === "draft" && (
<CompDocDialogFooterDraft
onMarkNotRequired={onMarkNotRequired}
onSaveDraft={onSaveDraft}
onGenerate={onGenerate}
/>
)}
{docState === "generated" && (
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
)}
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { FileText, X } from "lucide-react";
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = {
row: WorkOrderTableRow;
cfg: CompletionDocStateConfig;
onClose: () => void;
};
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{row.woNumber}
</span>
{" · "}
{serviceTitle(row)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
);
}

View file

@ -1,39 +1,15 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import {
DOC_STATE_CFG,
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar";
const FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
@ -51,102 +27,25 @@ export function CompDocDialog({
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const locId = String(row.locationId || "");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
useEffect(() => {
if (!open) return;
setNotes(data?.notes ?? "");
setDocState(docStateOf(row.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(row.completedDate || "");
}, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
const docFields: DocFields = {
street: site.street,
city: site.city,
state: site.state,
zip: site.zip,
dateCompleted: editDateCompleted,
const {
site,
notes,
setNotes,
docState,
generatedAt,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
};
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
onSave({ notes, generatedAt, ...extra }, status);
};
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
}
function handleUndo() {
setDocState("draft");
persist({}, "No");
}
function handleSaveDraft() {
persist({}, "No");
if (editDateCompleted !== (row.completedDate || "")) {
onDateCompletedChange?.(editDateCompleted);
}
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
<CheckCircle2 size={16} /> Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== (row.completedDate || "")) {
onDateCompletedChange?.(editDateCompleted);
}
}
function handleDownloadPdf() {
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
cfg,
isNotRequired,
isGenerated,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
} = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
return (
<Dialog
@ -183,88 +82,7 @@ export function CompDocDialog({
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span
className="font-mono"
style={{ color: "var(--color-primary)", fontWeight: 600 }}
>
#{row.woNumber}
</span>
{" · "}
{serviceTitle(row)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
<CompDocDialogHeader row={row} cfg={cfg} onClose={() => onOpenChange(false)} />
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
@ -290,107 +108,15 @@ export function CompDocDialog({
/>
</div>
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
{docState === "draft" && (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={handleMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
onClick={handleSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={handleGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
)}
{docState === "generated" && (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} /> Generated {generatedAt}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={handleDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
)}
{docState === "not-required" && (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
onClick={handleUndo}
>
<Undo2 size={14} /> Undo
</Button>
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
)}
</div>
<CompDocDialogFooter
docState={docState}
generatedAt={generatedAt}
onMarkNotRequired={handleMarkNotRequired}
onUndo={handleUndo}
onSaveDraft={handleSaveDraft}
onGenerate={handleGenerate}
onDownloadPdf={handleDownloadPdf}
/>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,29 @@
import { CheckCircle2 } from "lucide-react";
import { toast } from "react-toastify";
export function showCompDocDraftSavedToast() {
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
<CheckCircle2 size={16} /> Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}

View file

@ -0,0 +1,54 @@
import type { CSSProperties } from "react";
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)",
};
const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
opacity: 0.6,
cursor: "default",
pointerEvents: "none",
};
type CompDocEditableRowProps = {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
placeholder?: string;
disabled?: boolean;
};
export function CompDocEditableRow({
label,
value,
onChange,
type = "text",
placeholder = "",
disabled = false,
}: CompDocEditableRowProps) {
return (
<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={disabled}
placeholder={placeholder}
className={COMP_DOC_INPUT_CLS}
style={{
...COMP_DOC_INPUT_STYLE,
...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
}}
/>
</div>
);
}

View file

@ -0,0 +1,8 @@
export const COMP_DOC_FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;

View file

@ -0,0 +1,25 @@
import { Ban } from "lucide-react";
export function CompDocNotRequiredStamp() {
return (
<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>
);
}

View file

@ -0,0 +1,50 @@
type CompDocNotesFieldProps = {
notes: string;
onNotesChange: (value: string) => void;
isGenerated: boolean;
};
export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
return (
<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>
);
}

View file

@ -0,0 +1,32 @@
import { CheckCircle2, FileText } from "lucide-react";
type CompDocPreviewBadgeProps = {
isGenerated: boolean;
};
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
return (
<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>
);
}

View file

@ -0,0 +1,36 @@
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { CompDocCoLine } from "./comp-doc-co-line";
export function CompDocPreviewHeader() {
return (
<>
<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)",
}}
/>
</>
);
}

View file

@ -0,0 +1,87 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
notes: string;
};
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
<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"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { CheckCircle2 } 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 { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocPreviewSummaryProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
generatedAt: string;
isGenerated: boolean;
};
export function CompDocPreviewSummary({
row,
site,
generatedAt,
isGenerated,
}: CompDocPreviewSummaryProps) {
return (
<>
{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"
variant="heading"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
family="mono"
variant="body"
sx={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
variant="body"
sx={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
</>
);
}

View file

@ -1,12 +1,10 @@
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";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@ -25,8 +23,6 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
const proc = procedureFor(row.pm);
return (
<div
className="flex min-h-0 flex-col"
@ -36,28 +32,7 @@ export function CompDocPreview({
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>
<CompDocPreviewBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
@ -88,175 +63,14 @@ export function CompDocPreview({
}}
/>
<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)",
}}
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
{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"
variant="heading"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
family="mono"
variant="body"
sx={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
variant="body"
sx={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
<Text variant="body" sx={{ 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"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
<CompDocPreviewProcedure row={row} notes={notes} />
</div>
<div
style={{
@ -267,27 +81,7 @@ export function CompDocPreview({
/>
</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>
)}
{isNotRequired && <CompDocNotRequiredStamp />}
</div>
</div>
</div>

View file

@ -1,21 +1,8 @@
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)",
};
import { CompDocDetailsSection } from "./comp-doc-details-section";
import { CompDocNotesField } from "./comp-doc-notes-field";
import { CompDocStatusCard } from "./comp-doc-status-card";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
@ -44,163 +31,23 @@ export function CompDocSidebar({
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>
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
<CompDocDetailsSection
row={row}
site={site}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
/>
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
</div>
</div>
);

View file

@ -0,0 +1,61 @@
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
type CompDocStatusCardProps = {
docState: DocState;
generatedAt: string;
};
export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
const cfg = DOC_STATE_CFG[docState];
const StateIcon = cfg.icon;
return (
<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>
);
}

View file

@ -0,0 +1,129 @@
import { useEffect, useMemo, useState } from "react";
import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import {
DOC_STATE_CFG,
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
type UseCompDocDialogParams = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
export function useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
}: UseCompDocDialogParams) {
const locId = String(row.locationId || "");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
useEffect(() => {
if (!open) return;
setNotes(data?.notes ?? "");
setDocState(docStateOf(row.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(row.completedDate || "");
}, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
const docFields: DocFields = {
street: site.street,
city: site.city,
state: site.state,
zip: site.zip,
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
};
function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus) {
onSave({ notes, generatedAt, ...extra }, status);
}
function syncCompletedDateIfChanged() {
if (editDateCompleted !== (row.completedDate || "")) {
onDateCompletedChange?.(editDateCompleted);
}
}
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
}
function handleUndo() {
setDocState("draft");
persist({}, "No");
}
function handleSaveDraft() {
persist({}, "No");
syncCompletedDateIfChanged();
showCompDocDraftSavedToast();
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
syncCompletedDateIfChanged();
}
function handleDownloadPdf() {
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
return {
site,
notes,
setNotes,
docState,
generatedAt,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
};
}

View file

@ -149,6 +149,11 @@ export default function WorkOrdersListPage() {
if (!open) setDocRow(null);
}}
data={completionDocs[String(activeDocRow.id)]}
// docStatus and completedDate persist server-side through the real work-order
// update API; patchField serializes same-id saves so a status save and a
// date save never race each other. There is no notes/content persistence
// endpoint on this branch, so `data` (notes/generatedAt) stays client-local
// for the preview only.
onSave={(data, status) => {
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
patchField(activeDocRow.id, { docStatus: status });

View file

@ -109,6 +109,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
dueDate: draft.dueDate,
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
completedDate: "",
docStatus: "",
assignedTo: draft.assigneeName,
description: buildDescription(draft),
po: "",

View file

@ -290,6 +290,7 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
docStatus: readDocStatus(wo),
source: readString(wo, "source", "Source"),
comments: Array.isArray(wo.comments)
? wo.comments.map(mapWorkOrderComment)
@ -321,6 +322,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,
completedDate: detail.completedDate,
docStatus: detail.docStatus,
assignedTo: detail.assignedTo,
description: detail.description,
po: detail.po,
@ -346,6 +348,7 @@ export function mapWorkOrderFormToCreatePayload(
DueDate: values.dueDate || null,
ScheduledDate: values.scheduledDate || null,
CompletedDate: values.completedDate || null,
DocStatus: values.docStatus || null,
AssignTo: values.assignedTo || null,
Description: values.description,
PO: values.po || null,
@ -380,6 +383,7 @@ export function mapWorkOrderFormToUpdatePayload(
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
ScheduledDate: values.scheduledDate || null,
CompletedDate: values.completedDate || null,
DocStatus: values.docStatus || null,
CreatedDate: values.createdDate || null,
Source: values.source || null,
};

View file

@ -502,6 +502,7 @@ export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): Wo
vendorNTE: "",
scheduledDate: row.scheduledOn,
completedDate: row.status === "Completed" ? todayIso() : "",
docStatus: row.docStatus,
source: row.type,
comments: [],
auditLog: [],

View file

@ -150,6 +150,7 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder
vendorNTE: values.vendorNTE,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
docStatus: values.docStatus ?? "",
source: values.source,
comments: [],
auditLog: [],

View file

@ -1,5 +1,6 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
import type { CompletionDocStatus } from "./work-order-table-row";
export interface WorkOrderComment {
id: string | number;
@ -44,6 +45,8 @@ export interface WorkOrderDetail {
vendorNTE: string;
scheduledDate: string;
completedDate: string;
/** Completion-document status (API DocStatus/CompletionDoc); persisted via full update. */
docStatus: CompletionDocStatus;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
@ -61,6 +64,8 @@ export interface WorkOrderFormValues {
dueDate: string;
scheduledDate: string;
completedDate: string;
/** Optional: absent from the wizard's zod-validated shape, present once loaded from the server. */
docStatus?: CompletionDocStatus;
assignedTo: string;
description: string;
po: string;
@ -84,6 +89,7 @@ export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
dueDate: "",
scheduledDate: "",
completedDate: "",
docStatus: "",
assignedTo: "",
description: "",
po: "",

View file

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
@ -13,6 +14,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
@ -30,17 +32,72 @@ async function loadFormAndUpdate(
return detail;
}
function buildFullUpdateValues(
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
patch: WorkOrderTablePatch,
): ReturnType<typeof mapWorkOrderDetailToFormValues> {
return {
...values,
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
internalWONumber: patch.woNumber ?? values.internalWONumber,
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
dueDate: patch.dueDate ?? values.dueDate,
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
completedDate: patch.completedDate ?? values.completedDate,
docStatus: patch.docStatus ?? values.docStatus,
assignedTo: patch.dispatcherName ?? values.assignedTo,
problem: patch.pm ?? values.problem,
source: patch.type ?? values.source,
priority: values.priority,
status:
patch.status !== undefined
? mapWizardStatusToApi(patch.status as WizardWOStatus)
: values.status,
description: [
values.description,
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
patch.pocName || patch.pocPhone
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
: "",
]
.filter(Boolean)
.join("\n"),
};
}
async function runWorkOrderPatch(
id: string | number,
patch: WorkOrderTablePatch,
useFullUpdate: boolean | undefined,
): Promise<WorkOrderTablePatch> {
if (patch.status !== undefined && !useFullUpdate) {
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
await workOrdersApi.changeStatus(id, apiStatus);
return patch;
}
if (patch.dispatcherId !== undefined && !useFullUpdate) {
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
return {
...patch,
dispatcherName: patch.dispatcherName ?? "",
};
}
await loadFormAndUpdate(id, (values) => buildFullUpdateValues(values, patch));
return patch;
}
export function useWorkOrderTableMutations(
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
) {
const queryClient = useQueryClient();
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
mutationFn: async ({
mutationFn: ({
id,
patch,
useFullUpdate,
@ -48,47 +105,7 @@ export function useWorkOrderTableMutations(
id: string | number;
patch: WorkOrderTablePatch;
useFullUpdate?: boolean;
}) => {
if (patch.status !== undefined && !useFullUpdate) {
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
await workOrdersApi.changeStatus(id, apiStatus);
return patch;
}
if (patch.dispatcherId !== undefined && !useFullUpdate) {
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
return {
...patch,
dispatcherName: patch.dispatcherName ?? "",
};
}
await loadFormAndUpdate(id, (values) => ({
...values,
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
internalWONumber: patch.woNumber ?? values.internalWONumber,
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
dueDate: patch.dueDate ?? values.dueDate,
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
completedDate: patch.completedDate ?? values.completedDate,
assignedTo: patch.dispatcherName ?? values.assignedTo,
problem: patch.pm ?? values.problem,
source: patch.type ?? values.source,
priority: values.priority,
status:
patch.status !== undefined
? mapWizardStatusToApi(patch.status as WizardWOStatus)
: values.status,
description: [
values.description,
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
patch.pocName || patch.pocPhone
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
: "",
]
.filter(Boolean)
.join("\n"),
}));
return patch;
},
}) => enqueuePatch(id, () => runWorkOrderPatch(id, patch, useFullUpdate)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},

View file

@ -0,0 +1,20 @@
/**
* Serializes async work per work-order id so concurrent patches (e.g. a completion-doc
* status save firing alongside a completed-date save) never race two overlapping
* get-then-update round trips against the same record. Each call for a given id waits
* for the previous one to settle before starting its own fetch-and-update cycle.
*/
export function createWorkOrderPatchQueue() {
const chains = new Map<string, Promise<unknown>>();
return function enqueue<T>(id: string | number, run: () => Promise<T>): Promise<T> {
const key = String(id);
const previous = chains.get(key) ?? Promise.resolve();
const settled = previous.catch(() => undefined).then(run);
chains.set(
key,
settled.catch(() => undefined),
);
return settled;
};
}

View file

@ -64,4 +64,18 @@ describe("mapWorkOrderFormToUpdatePayload", () => {
expect(payload.PO).toBeNull();
expect(payload.LocationId).toBeNull();
});
it("persists the completion-document status alongside the completed date", () => {
const values = {
...createDefaultWorkOrderFormValues(),
createdDate: "2026-01-15",
completedDate: "2026-07-20",
docStatus: "Yes" as const,
};
const payload = mapWorkOrderFormToUpdatePayload(1, values);
expect(payload.CompletedDate).toBe("2026-07-20");
expect(payload.DocStatus).toBe("Yes");
});
});

View file

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
mapUpliftRequest,
mapWorkOrderDetail,
mapWorkOrderDetailToFormValues,
mapWorkOrderDispatchSummary,
} from "@/domain/work-orders/mappers/work-order-mapper";
@ -22,6 +24,27 @@ describe("mapWorkOrderDispatchSummary", () => {
});
});
describe("mapWorkOrderDetail completion-document status", () => {
it("reads DocStatus from the API payload so it round-trips into form values", () => {
const detail = mapWorkOrderDetail({
Id: 42,
CompletedDate: "2026-07-20",
DocStatus: "Yes",
});
expect(detail.docStatus).toBe("Yes");
const formValues = mapWorkOrderDetailToFormValues(detail);
expect(formValues.docStatus).toBe("Yes");
expect(formValues.completedDate).toBe("2026-07-20");
});
it("falls back to an empty doc status for unknown/missing values", () => {
const detail = mapWorkOrderDetail({ Id: 1 });
expect(detail.docStatus).toBe("");
});
});
describe("mapUpliftRequest", () => {
it("maps decided uplift fields from PascalCase payload", () => {
const result = mapUpliftRequest({

View file

@ -0,0 +1,156 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderDetail, WorkOrderFormValues } from "@/domain/work-orders/types/work-order";
const getById = vi.fn();
const update = vi.fn();
const changeStatus = vi.fn();
const changeAssignment = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getById: (...args: unknown[]) => getById(...args),
update: (...args: unknown[]) => update(...args),
changeStatus: (...args: unknown[]) => changeStatus(...args),
changeAssignment: (...args: unknown[]) => changeAssignment(...args),
},
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
const BASE_DETAIL: WorkOrderDetail = {
id: 42,
internalWONumber: "WO-42",
workOrderNumber: "WO-42",
title: "PM — SEA1",
location: "Seattle",
locationId: "loc-1",
locationZip: "98101",
assignedTo: "Dispatcher",
dueDate: "",
createdDate: "2026-06-01",
createdDateFull: "2026-06-01T00:00:00Z",
description: "",
priority: "Sev 3",
status: "Open",
po: "",
tt: "",
problem: "HVAC",
trade: "",
subTrade: "",
vendorNTE: "",
scheduledDate: "2026-06-01",
completedDate: "",
docStatus: "No",
source: "PM",
comments: [],
auditLog: [],
dispatches: [],
};
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: WorkOrderDetail;
let events: string[];
beforeEach(() => {
getById.mockReset();
update.mockReset();
changeStatus.mockReset();
changeAssignment.mockReset();
serverState = { ...BASE_DETAIL };
events = [];
getById.mockImplementation(async () => {
events.push("getById:start");
await delay(15);
events.push("getById:end");
return { ...serverState };
});
update.mockImplementation(async (_id: unknown, values: WorkOrderFormValues) => {
events.push("update:start");
await delay(5);
serverState = {
...serverState,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
docStatus: values.docStatus ?? serverState.docStatus,
status: values.status,
};
events.push("update:end");
return { ...serverState };
});
});
it("maps docStatus into the real full-update payload instead of dropping it", async () => {
const onPatch = vi.fn();
const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
wrapper: makeWrapper(),
});
act(() => {
result.current.patchField(42, { docStatus: "Yes" });
});
await waitFor(() => expect(events).toContain("update:end"));
expect(update.mock.calls[0]?.[1]).toMatchObject({ docStatus: "Yes" });
expect(serverState.docStatus).toBe("Yes");
});
it("serializes a completed-date save fired alongside a status save instead of racing", async () => {
const onPatch = vi.fn();
const { result } = renderHook(() => useWorkOrderTableMutations(onPatch), {
wrapper: makeWrapper(),
});
act(() => {
result.current.patchField(42, { docStatus: "Yes" });
result.current.patchField(42, { completedDate: "2026-07-20" });
});
await waitFor(() => expect(events.filter((event) => event === "update:end")).toHaveLength(2));
// The second full-update cycle must not start its GET until the first PUT settled.
expect(events).toEqual([
"getById:start",
"getById:end",
"update:start",
"update:end",
"getById:start",
"getById:end",
"update:start",
"update:end",
]);
// Neither save clobbers the other: both the status and the date survive.
expect(serverState.docStatus).toBe("Yes");
expect(serverState.completedDate).toBe("2026-07-20");
// Scheduling and lifecycle status are untouched by either completion-doc save.
expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
expect(serverState.status).toBe(BASE_DETAIL.status);
for (const call of update.mock.calls) {
const values = call[1] as WorkOrderFormValues;
expect(values.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
expect(values.status).not.toBe("Done");
}
});
});

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("createWorkOrderPatchQueue", () => {
it("serializes calls for the same id so the second never starts before the first settles", async () => {
const enqueue = createWorkOrderPatchQueue();
const events: string[] = [];
const first = enqueue(1, async () => {
events.push("first:start");
await delay(20);
events.push("first:end");
return "first";
});
const second = enqueue(1, async () => {
events.push("second:start");
await delay(1);
events.push("second:end");
return "second";
});
await Promise.all([first, second]);
expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
});
it("does not block work queued for a different id", async () => {
const enqueue = createWorkOrderPatchQueue();
const events: string[] = [];
const slow = enqueue(1, async () => {
events.push("slow:start");
await delay(20);
events.push("slow:end");
});
const fast = enqueue(2, async () => {
events.push("fast:start");
await delay(1);
events.push("fast:end");
});
await Promise.all([slow, fast]);
expect(events.indexOf("fast:end")).toBeLessThan(events.indexOf("slow:end"));
});
it("still runs the next task after a prior task rejects", async () => {
const enqueue = createWorkOrderPatchQueue();
await expect(
enqueue(1, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
await expect(enqueue(1, async () => "recovered")).resolves.toBe("recovered");
});
});