feat(work-orders): add slide-over detail and completion doc UI

This commit is contained in:
Arthur Bassi 2026-07-17 12:08:59 -03:00
parent 2dd885a499
commit f2d7544a40
12 changed files with 1529 additions and 105 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,402 @@
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 { 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 type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
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;
onStatusChange?: (status: WizardWOStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
onSave,
onStatusChange,
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.scheduledOn || "");
}, [open, row.id, row.docStatus, row.scheduledOn, 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.scheduledOn) {
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",
bodyClassName: "!p-0",
},
);
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== row.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
onStatusChange?.("Completed");
}
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>
<p className="sr-only">
Generate, save or mark not-required the completion document for this work order.
</p>
<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,280 @@
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } 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 { 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>
)}
<h2
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</h2>
<p
className="font-mono"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</p>
<div style={{ marginTop: 14 }}>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</p>
<p style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</p>
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.street},</p>
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.label}</p>
</div>
<p style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</p>
<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>
<h3
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</h3>
<p
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</p>
<CompDocProcedureList proc={proc} />
{notes && (
<>
<h3
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</h3>
<p
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</p>
</>
)}
</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 && (
<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

@ -0,0 +1,43 @@
import { Button, Menu, MenuItem } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderAssigneeMenuProps = {
assignedTo: string;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
};
export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrderAssigneeMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<Button size="small" onClick={(event) => setAnchor(event.currentTarget)}>
{assignedTo || "Unassigned"}
</Button>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
<MenuItem
onClick={() => {
close();
onAssign("", "");
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={assignedTo === user.name}
onClick={() => {
close();
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -0,0 +1,293 @@
import { useMemo, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Paper,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
import type {
WorkOrderAuditEntry,
WorkOrderDetail,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
type ActivityTab = "customer" | "internal" | "audit";
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return <Typography color="text.secondary">No changes recorded.</Typography>;
}
return (
<Box className="max-h-72 space-y-4 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Box className="mb-1 flex justify-between">
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Box>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Box>
);
}
type WorkOrderDetailsSectionProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
onDispatchClick: (dispatchId: string | number) => void;
onCommentSubmit: (text: string, commentType: "customer" | "internal") => void;
isCommentPending: boolean;
};
export function WorkOrderDetailsSection({
wo,
users,
onAssign,
onDispatchClick,
onCommentSubmit,
isCommentPending,
}: WorkOrderDetailsSectionProps) {
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const customerComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo.comments],
);
const internalComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo.comments],
);
const auditLog = wo.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal");
setCommentText("");
};
return (
<>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<DetailFieldGrid>
<DetailField label="Internal WO" value={wo.internalWONumber} />
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
<DetailField label="Priority" value={wo.priority || "—"} />
<DetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Assigned To
</Typography>
<WorkOrderAssigneeMenu assignedTo={wo.assignedTo} users={users} onAssign={onAssign} />
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
</DetailFieldGrid>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<DetailFieldGrid>
<DetailField label="Problem" value={wo.problem || "—"} />
<DetailField label="Trade" value={wo.trade || "—"} />
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
</DetailFieldGrid>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailField
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<DetailField label="Due Date" value={wo.dueDate || "—"} />
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
<DetailField label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<DetailField
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<DetailField label="Customer PO" value={wo.po || "—"} />
<DetailField label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const color = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => onDispatchClick(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography color="text.secondary">No dispatches yet.</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(customerComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "internal" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(internalComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={isCommentPending}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={isCommentPending || !commentText.trim()}
>
{isCommentPending ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
</>
);
}

View file

@ -0,0 +1,142 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, CircularProgress, IconButton, Tab, Tabs, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { SideSheet } from "@/components/ui/side-sheet";
import { WorkOrderDetailsSection } from "@/app/(protected)/workorders/_components/detail/work-order-detail-sections";
import { WorkOrderStatusMenu } from "@/app/(protected)/workorders/_components/detail/work-order-status-menu";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
users: WorkOrderUser[];
onClose: () => void;
};
export function WorkOrderSlideOver({ row, editMode, users, onClose }: WorkOrderSlideOverProps) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const workOrderId = row?.id;
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
const statusMutation = useChangeWorkOrderStatus(workOrderId ?? "");
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId ?? "");
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
return (
<SideSheet
open={Boolean(row)}
onClose={onClose}
widthClass="w-full max-w-xl"
aria-label="Work order details"
>
<Box className="flex h-full flex-col">
<Box className="flex items-start justify-between border-b border-border px-4 py-3">
<Box>
<Typography variant="overline" color="text.secondary">
Work Order
</Typography>
<Typography variant="h6" component="h2">
{row?.woNumber || row?.title || "—"}
</Typography>
{wo && (
<Box className="mt-1">
<WorkOrderStatusMenu
status={wo.status}
onStatusChange={(status) => statusMutation.mutate(status)}
/>
</Box>
)}
</Box>
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} className="border-b border-border px-2">
<Tab label="Info" />
<Tab label="Activity" />
</Tabs>
<Box className="flex-1 overflow-y-auto p-4">
{isLoading && (
<Box className="flex justify-center py-12">
<CircularProgress size={28} />
</Box>
)}
{error && (
<Typography color="error" variant="body2">
{error instanceof Error ? error.message : "Failed to load work order."}
</Typography>
)}
{wo && tab === 0 && (
<Box className="space-y-3">
<Typography variant="body2">
<strong>Title:</strong> {wo.title}
</Typography>
<Typography variant="body2">
<strong>Location:</strong> {wo.location}
</Typography>
<Typography variant="body2">
<strong>Assigned:</strong> {wo.assignedTo || "Unassigned"}
</Typography>
<Typography variant="body2">
<strong>Due:</strong> {wo.dueDate || "—"}
</Typography>
<Typography variant="body2">
<strong>Scheduled:</strong> {wo.scheduledDate || "—"}
</Typography>
<Typography variant="body2">
<strong>Problem:</strong> {wo.problem || "—"}
</Typography>
{wo.description && (
<Typography variant="body2" className="whitespace-pre-wrap">
{wo.description}
</Typography>
)}
<Button
variant="outlined"
size="small"
onClick={() => {
onClose();
navigate(`/workorders/${wo.id}`);
}}
>
Open full page
</Button>
{editMode && (
<Button
variant="contained"
size="small"
onClick={() => {
onClose();
navigate(`/workorders/edit/${wo.id}`);
}}
>
Edit work order
</Button>
)}
</Box>
)}
{wo && tab === 1 && (
<WorkOrderDetailsSection
wo={wo}
users={users}
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
onDispatchClick={() => {}}
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
isCommentPending={commentMutation.isPending}
/>
)}
</Box>
</Box>
</SideSheet>
);
}

View file

@ -0,0 +1,38 @@
import { Menu, MenuItem } from "@mui/material";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = {
status: string;
onStatusChange: (status: string) => void;
};
export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<MenuAnchorButton onAnchor={setAnchor} open={open} aria-label="Change status">
<WorkOrderStatusBadge status={status} />
</MenuAnchorButton>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
<MenuItem
key={option}
selected={option === status}
onClick={() => {
close();
if (option !== status) {
onStatusChange(option);
}
}}
>
{option}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -15,31 +15,22 @@ import {
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useDebounce } from "@/hooks/use-debounce";
import { useWorkOrdersByLocation } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
useTaskListTemplates,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
interface SelectedWorkOrder {
id: string | number;
number: string;
title: string;
}
import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
interface DispatchCreateModalProps {
workOrder: WorkOrderDetail;
onClose: () => void;
onDispatched: () => void;
onDispatched?: () => void;
}
export function DispatchCreateModal({
workOrder,
onClose,
onDispatched,
}: DispatchCreateModalProps) {
export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalProps) {
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
const [scheduledDate, setScheduledDate] = useState("");
@ -48,7 +39,7 @@ export function DispatchCreateModal({
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [customItems, setCustomItems] = useState<string[]>([]);
const [newCustomItem, setNewCustomItem] = useState("");
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
{
id: workOrder.id,
number: workOrder.internalWONumber,
@ -56,6 +47,7 @@ export function DispatchCreateModal({
},
]);
const [woSearch, setWoSearch] = useState("");
const debouncedWoSearch = useDebounce(woSearch, 300);
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
workOrder.trade,
@ -64,14 +56,10 @@ export function DispatchCreateModal({
const { data: taskTemplates = [] } = useTaskListTemplates();
const dispatchMutation = useDispatchToVendor(workOrder.id);
const { data: siteWorkOrdersData } = useWorkOrdersList({
search: "",
page: 1,
pageSize: 500,
sortBy: "number",
sortDir: "asc",
locationId: workOrder.locationId,
});
const { data: siteWorkOrdersData } = useWorkOrdersByLocation(
workOrder.locationId,
debouncedWoSearch,
);
const siteWorkOrders = useMemo(
() =>
@ -93,14 +81,8 @@ export function DispatchCreateModal({
() =>
siteWorkOrders
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
.filter(
(item) =>
!woSearch ||
item.number.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
[siteWorkOrders, selectedWOs, woSearch],
[siteWorkOrders, selectedWOs],
);
const handleSubmit = () => {
@ -118,12 +100,7 @@ export function DispatchCreateModal({
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
customChecklistItems: customItems.length > 0 ? customItems : null,
},
{
onSuccess: () => {
onDispatched();
onClose();
},
},
{ onSuccess: () => onClose() },
);
};
@ -138,18 +115,13 @@ export function DispatchCreateModal({
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<Box>
<Typography variant="subtitle2" className="mb-2">
Work Orders ({selectedWOs.length})
</Typography>
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
<Stack direction="row" flexWrap="wrap" gap={1}>
{selectedWOs.map((wo) => (
<Chip
key={String(wo.id)}
@ -180,13 +152,13 @@ export function DispatchCreateModal({
onClick={() => {
setSelectedWOs((prev) => [
...prev,
{ id: item.id, number: item.number, title: item.title },
{ id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
<Typography variant="body2">
<strong>{item.number}</strong> — {item.title}
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}
@ -208,21 +180,9 @@ export function DispatchCreateModal({
className="mb-2"
/>
{isLoadingVendors ? (
<Typography
sx={{
color: "text.secondary",
}}
>
Loading vendors...
</Typography>
<Typography color="text.secondary">Loading vendors...</Typography>
) : filteredVendors.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendors found
</Typography>
<Typography color="text.secondary">No vendors found</Typography>
) : (
<Stack spacing={1} className="max-h-60 overflow-y-auto">
{filteredVendors.map((vendor) => (
@ -243,24 +203,12 @@ export function DispatchCreateModal({
{vendor.companyName}
</Typography>
{vendor.tradeSpecialties && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
<Typography variant="caption" color="text.secondary" display="block">
{vendor.tradeSpecialties}
</Typography>
)}
{vendor.address && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
<Typography variant="caption" color="text.secondary" display="block">
{vendor.address}
</Typography>
)}
@ -282,10 +230,8 @@ export function DispatchCreateModal({
type="number"
value={nteAmount}
onChange={(event) => setNteAmount(event.target.value)}
inputProps={{ step: "0.01", min: 0 }}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField
label="Vendor Scheduled Date"
@ -313,13 +259,7 @@ export function DispatchCreateModal({
</TextField>
{customItems.length > 0 && (
<Stack
direction="row"
sx={{
flexWrap: "wrap",
gap: 1,
}}
>
<Stack direction="row" flexWrap="wrap" gap={1}>
{customItems.map((item, index) => (
<Chip
key={`${item}-${index}`}
@ -354,6 +294,7 @@ export function DispatchCreateModal({
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
Cancel

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Autocomplete,
@ -67,7 +67,7 @@ export default function WorkOrderFormPage() {
formState: { errors },
} = useForm<WorkOrderFormSchemaValues>({
resolver: zodResolver(workOrderFormSchema),
defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
defaultValues: createDefaultWorkOrderFormValues(),
});
const tradeValue = useWatch({ control, name: "trade" });
@ -80,7 +80,7 @@ export default function WorkOrderFormPage() {
useEffect(() => {
if (isEdit && detail) {
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
reset(mapWorkOrderDetailToFormValues(detail));
}
}, [detail, isEdit, reset]);
@ -90,7 +90,7 @@ export default function WorkOrderFormPage() {
}
}, [tradeValue, setValue]);
const locationOptions = useMemo(() => locations, [locations]);
const locationOptions: LocationOption[] = locations;
const isSaving = createMutation.isPending || updateMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
@ -131,17 +131,13 @@ export default function WorkOrderFormPage() {
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{isEdit ? "Edit" : "Create"}
</Typography>
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
</Breadcrumbs>
<Typography variant="h5" component="h1">
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
</Typography>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box>
@ -319,10 +315,8 @@ export default function WorkOrderFormPage() {
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
inputProps={{ step: "0.01", min: 0 }}
fullWidth
slotProps={{
htmlInput: { step: "0.01", min: 0 },
}}
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
@ -346,13 +340,7 @@ export default function WorkOrderFormPage() {
/>
</Box>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>