mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 14:23:13 +00:00
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:
parent
725efd2703
commit
b7056f569e
33 changed files with 1360 additions and 737 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: </span>
|
||||
{row.tech || "—"}
|
||||
{row.company ? ` · ${row.company}` : ""}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: </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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
129
src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
Normal file
129
src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal file
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue