feat(work-orders): slim completion-doc UI onto current dev

Keep SH-116 CompDocDialog wiring and completedDate board contract.
Drop slide-over ownership (SH-112) so it stays in PR #32.
This commit is contained in:
Arthur Bassi 2026-07-27 09:47:46 -03:00
parent 94bd10245b
commit 725efd2703
14 changed files with 1155 additions and 0 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,398 @@
import { useEffect, useMemo, useState, 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 { 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 { 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;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
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,
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));
}
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1024px, calc(100vw - 32px))",
maxWidth: "none",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent
dividers={false}
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
sx={{
p: 0,
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</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>
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
<CompDocSidebar
row={row}
site={site}
docState={docState}
generatedAt={generatedAt}
notes={notes}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
onNotesChange={setNotes}
onDateCompletedChange={setEditDateCompleted}
/>
</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>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,295 @@
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"
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>
</>
)}
</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,35 @@
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) => {
const subSteps = st.sub ?? [];
return (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{subSteps.length > 0 && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{subSteps.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

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
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";
@ -19,6 +20,7 @@ import {
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
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";
@ -28,7 +30,9 @@ export default function WorkOrdersListPage() {
const navigate = useNavigate();
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
@ -55,6 +59,11 @@ export default function WorkOrdersListPage() {
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
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;
@ -112,6 +121,7 @@ export default function WorkOrdersListPage() {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
onOpenCompDoc={setDocRow}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
@ -131,6 +141,22 @@ export default function WorkOrdersListPage() {
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
{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 });
}}
onDateCompletedChange={(date) => patchField(activeDocRow.id, { completedDate: date })}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}

View file

@ -179,6 +179,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
targetWeek: readString(item, "targetWeek", "TargetWeek"),
dayGroup,
dayLabel,

View file

@ -70,6 +70,7 @@ function buildRow(seed: RowSeed): WorkOrderTableRow {
...seed,
...dayFields,
scheduledOn,
completedDate: seed.status === "Completed" ? todayIso() : "",
targetWeek: seed.targetWeek ?? "",
isPastDue: false,
});

View file

@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
completedDate: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;

View file

@ -68,6 +68,7 @@ export function useWorkOrderTableMutations(
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,

View file

@ -0,0 +1,129 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("react-toastify", () => ({
toast: vi.fn(),
}));
const SCHEDULED_ON = "2026-06-01";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON,
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Seattle",
locationId: "mock-loc-1",
pm: "HVAC",
company: "Vendor Co",
tech: "Tech One",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
...overrides,
};
}
describe("CompDocDialog Save Draft / Generate completion contract", () => {
const onSave = vi.fn();
const onDateCompletedChange = vi.fn();
beforeEach(() => {
onSave.mockReset();
onDateCompletedChange.mockReset();
});
function renderDialog(row: WorkOrderTableRow) {
// Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
const patches: Array<Partial<WorkOrderTableRow>> = [];
render(
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={(data, status) => {
onSave(data, status);
patches.push({ docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange(date);
patches.push({ completedDate: date });
}}
/>,
);
return { patches };
}
function changeDateCompleted(value: string) {
const dateInput = document.querySelector('input[type="date"]');
expect(dateInput).not.toBeNull();
fireEvent.change(dateInput!, { target: { value } });
}
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => {
const row = baseRow();
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-15");
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]);
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
expect(row.scheduledOn).toBe(SCHEDULED_ON);
expect(row.status).toBe("Scheduled");
});
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => {
const row = baseRow({ completedDate: "2026-07-01" });
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ generatedAt: expect.any(String) }),
"Yes",
);
expect(patches).toEqual([{ docStatus: "Yes" }, { completedDate: "2026-07-20" }]);
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
expect(row.scheduledOn).toBe(SCHEDULED_ON);
expect(row.status).toBe("Scheduled");
});
});

View file

@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "2026-07-20",
dayGroup: "2026-07-20",
dayLabel: "Mon",

View file

@ -18,6 +18,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",
completedDate: "",
targetWeek: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",