mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 13:32:06 +00:00
feat(work-orders): slim completion-doc UI onto current dev
Keep SH-116 CompDocDialog wiring and completedDate board contract. Drop slide-over ownership (SH-112) so it stays in PR #32.
This commit is contained in:
parent
94bd10245b
commit
725efd2703
14 changed files with 1155 additions and 0 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocFields,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||||
|
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||||
|
import {
|
||||||
|
DOC_STATE_CFG,
|
||||||
|
docStateOf,
|
||||||
|
fmtDocNow,
|
||||||
|
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
import { CompDocPreview } from "./comp-doc-preview";
|
||||||
|
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||||
|
|
||||||
|
const FOOTER_BTN_SX = {
|
||||||
|
minHeight: 28,
|
||||||
|
height: 28,
|
||||||
|
fontSize: 12,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.5,
|
||||||
|
gap: 0.75,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type CompDocDialogProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
|
||||||
|
onDateCompletedChange?: (date: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialog({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocDialogProps) {
|
||||||
|
const locId = String(row.locationId || "");
|
||||||
|
const isMockLocation = locId.startsWith("mock-");
|
||||||
|
const { data: apiLocationDetail } = useLocationDetail(
|
||||||
|
isMockLocation || !locId ? undefined : locId,
|
||||||
|
);
|
||||||
|
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||||
|
|
||||||
|
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [docState, setDocState] = useState<DocState>("draft");
|
||||||
|
const [generatedAt, setGeneratedAt] = useState("");
|
||||||
|
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(row.docStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(row.completedDate || "");
|
||||||
|
}, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
|
||||||
|
|
||||||
|
const amazonRep = row.pocName || "";
|
||||||
|
const seaRep = row.tech || "";
|
||||||
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
|
const isNotRequired = docState === "not-required";
|
||||||
|
const isGenerated = docState === "generated";
|
||||||
|
|
||||||
|
const docFields: DocFields = {
|
||||||
|
street: site.street,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
zip: site.zip,
|
||||||
|
dateCompleted: editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMarkNotRequired() {
|
||||||
|
setDocState("not-required");
|
||||||
|
persist({}, "NN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
setDocState("draft");
|
||||||
|
persist({}, "No");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveDraft() {
|
||||||
|
persist({}, "No");
|
||||||
|
if (editDateCompleted !== (row.completedDate || "")) {
|
||||||
|
onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
toast(
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--foreground)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} /> Draft saved
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: "bottom-center",
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
icon: false,
|
||||||
|
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
persist({ generatedAt: ts }, "Yes");
|
||||||
|
if (editDateCompleted !== (row.completedDate || "")) {
|
||||||
|
onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadPdf() {
|
||||||
|
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||||
|
style: {
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "min(1024px, calc(100vw - 32px))",
|
||||||
|
maxWidth: "none",
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
dividers={false}
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||||
|
<Text className="sr-only" variant="description">
|
||||||
|
Generate, save or mark not-required the completion document for this work order.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
padding: "0 20px",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completion Document
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO{" "}
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ color: "var(--color-primary)", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
#{row.woNumber}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{serviceTitle(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillBg,
|
||||||
|
color: cfg.pillText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.pillLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
|
<CompDocPreview
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
notes={notes}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
isNotRequired={isNotRequired}
|
||||||
|
/>
|
||||||
|
<CompDocSidebar
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
docState={docState}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
notes={notes}
|
||||||
|
editDateCompleted={editDateCompleted}
|
||||||
|
amazonRep={amazonRep}
|
||||||
|
seaRep={seaRep}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
onDateCompletedChange={setEditDateCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{docState === "draft" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
onClick={handleMarkNotRequired}
|
||||||
|
>
|
||||||
|
<Ban size={14} /> Mark as not required
|
||||||
|
</Button>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docState === "generated" && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> Generated {generatedAt}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docState === "not-required" && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
||||||
|
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
onClick={handleUndo}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} /> Undo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
disabled
|
||||||
|
title="Marked not required — undo to generate"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||||
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
|
|
||||||
|
type CompDocPreviewProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreview({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewProps) {
|
||||||
|
const proc = procedureFor(row.pm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "58%",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
padding: "0 12px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGenerated ? (
|
||||||
|
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
||||||
|
) : (
|
||||||
|
<FileText size={13} />
|
||||||
|
)}
|
||||||
|
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
style={{ padding: "0 20px 20px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative break-words"
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-start justify-between gap-y-3"
|
||||||
|
style={{ padding: "20px 0 12px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src="/seahaven-logo.svg"
|
||||||
|
alt="Seahaven Industries"
|
||||||
|
style={{ height: 34, width: "auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
||||||
|
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
||||||
|
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
||||||
|
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
||||||
|
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isGenerated && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--doc-yes-bg)",
|
||||||
|
color: "var(--color-success)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceTitle(row).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
family="mono"
|
||||||
|
variant="body"
|
||||||
|
sx={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1C75BC",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO# {row.woNumber}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site ID — {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||||
|
Amazon.com Services LLC {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.street},
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||||
|
{row.tech || "—"}
|
||||||
|
{row.company ? ` · ${row.company}` : ""}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||||
|
{SAFETY_BULLETS.map((b, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: "5px 0",
|
||||||
|
color: "#161E38",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.heading}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
sx={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#58595B",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.intro}
|
||||||
|
</Text>
|
||||||
|
<CompDocProcedureList proc={proc} />
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
sx={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNotRequired && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
marginTop: "30%",
|
||||||
|
transform: "rotate(-15deg)",
|
||||||
|
border: "3px solid var(--warning)",
|
||||||
|
color: "var(--warning)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 18px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 22,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
background: "rgba(255,255,255,0.65)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={20} /> NOT REQUIRED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocProcedureListProps = {
|
||||||
|
proc: Procedure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||||
|
return (
|
||||||
|
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||||
|
{proc.steps.map((st, i) => {
|
||||||
|
const subSteps = st.sub ?? [];
|
||||||
|
return (
|
||||||
|
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
{st.text}
|
||||||
|
{subSteps.length > 0 && (
|
||||||
|
<ol
|
||||||
|
style={{
|
||||||
|
listStyle: "lower-alpha",
|
||||||
|
paddingLeft: 22,
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subSteps.map((x, j) => (
|
||||||
|
<li key={j} style={{ margin: "2px 0" }}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||||
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||||
|
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
||||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
|
@ -28,7 +30,9 @@ export default function WorkOrdersListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||||
const filters = useWorkOrdersListFilters(user?.id);
|
const filters = useWorkOrdersListFilters(user?.id);
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
|
@ -55,6 +59,11 @@ export default function WorkOrdersListPage() {
|
||||||
const totalCount = tableData.totalCount;
|
const totalCount = tableData.totalCount;
|
||||||
const scopeTotalCount = tableData.scopeTotalCount;
|
const scopeTotalCount = tableData.scopeTotalCount;
|
||||||
|
|
||||||
|
const activeDocRow = useMemo(() => {
|
||||||
|
if (!docRow) return null;
|
||||||
|
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
|
||||||
|
}, [docRow, tableData.rows]);
|
||||||
|
|
||||||
const activeMediaRow = useMemo(() => {
|
const activeMediaRow = useMemo(() => {
|
||||||
if (!mediaRow) return null;
|
if (!mediaRow) return null;
|
||||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
|
|
@ -112,6 +121,7 @@ export default function WorkOrdersListPage() {
|
||||||
tableData.setSelectedId(row.id);
|
tableData.setSelectedId(row.id);
|
||||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||||
}}
|
}}
|
||||||
|
onOpenCompDoc={setDocRow}
|
||||||
onOpenMedia={setMediaRow}
|
onOpenMedia={setMediaRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
|
|
@ -131,6 +141,22 @@ export default function WorkOrdersListPage() {
|
||||||
|
|
||||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||||
|
|
||||||
|
{activeDocRow != null && (
|
||||||
|
<CompDocDialog
|
||||||
|
row={activeDocRow}
|
||||||
|
open={!!activeDocRow}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDocRow(null);
|
||||||
|
}}
|
||||||
|
data={completionDocs[String(activeDocRow.id)]}
|
||||||
|
onSave={(data, status) => {
|
||||||
|
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
||||||
|
patchField(activeDocRow.id, { docStatus: status });
|
||||||
|
}}
|
||||||
|
onDateCompletedChange={(date) => patchField(activeDocRow.id, { completedDate: date })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeMediaRow != null && (
|
{activeMediaRow != null && (
|
||||||
<MediaUploadModal
|
<MediaUploadModal
|
||||||
row={activeMediaRow}
|
row={activeMediaRow}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
dispatcherName,
|
dispatcherName,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
|
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
||||||
targetWeek: readString(item, "targetWeek", "TargetWeek"),
|
targetWeek: readString(item, "targetWeek", "TargetWeek"),
|
||||||
dayGroup,
|
dayGroup,
|
||||||
dayLabel,
|
dayLabel,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ function buildRow(seed: RowSeed): WorkOrderTableRow {
|
||||||
...seed,
|
...seed,
|
||||||
...dayFields,
|
...dayFields,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
|
completedDate: seed.status === "Completed" ? todayIso() : "",
|
||||||
targetWeek: seed.targetWeek ?? "",
|
targetWeek: seed.targetWeek ?? "",
|
||||||
isPastDue: false,
|
isPastDue: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
dispatcherName: string;
|
dispatcherName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
|
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
|
||||||
|
completedDate: string;
|
||||||
targetWeek: string;
|
targetWeek: string;
|
||||||
dayGroup: string;
|
dayGroup: string;
|
||||||
dayLabel: string;
|
dayLabel: string;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export function useWorkOrderTableMutations(
|
||||||
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
|
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
|
||||||
dueDate: patch.dueDate ?? values.dueDate,
|
dueDate: patch.dueDate ?? values.dueDate,
|
||||||
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
|
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
|
||||||
|
completedDate: patch.completedDate ?? values.completedDate,
|
||||||
assignedTo: patch.dispatcherName ?? values.assignedTo,
|
assignedTo: patch.dispatcherName ?? values.assignedTo,
|
||||||
problem: patch.pm ?? values.problem,
|
problem: patch.pm ?? values.problem,
|
||||||
source: patch.type ?? values.source,
|
source: patch.type ?? values.source,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||||
|
useLocationDetail: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SCHEDULED_ON = "2026-06-01";
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "WO-42",
|
||||||
|
site: "SEA1",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
scheduledOn: SCHEDULED_ON,
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Seattle",
|
||||||
|
locationId: "mock-loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
company: "Vendor Co",
|
||||||
|
tech: "Tech One",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "Amazon Rep",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "PM — SEA1",
|
||||||
|
priority: "Sev 3",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onDateCompletedChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onSave.mockReset();
|
||||||
|
onDateCompletedChange.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDialog(row: WorkOrderTableRow) {
|
||||||
|
// Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
|
||||||
|
const patches: Array<Partial<WorkOrderTableRow>> = [];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CompDocDialog
|
||||||
|
row={row}
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
onSave={(data, status) => {
|
||||||
|
onSave(data, status);
|
||||||
|
patches.push({ docStatus: status });
|
||||||
|
}}
|
||||||
|
onDateCompletedChange={(date) => {
|
||||||
|
onDateCompletedChange(date);
|
||||||
|
patches.push({ completedDate: date });
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { patches };
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDateCompleted(value: string) {
|
||||||
|
const dateInput = document.querySelector('input[type="date"]');
|
||||||
|
expect(dateInput).not.toBeNull();
|
||||||
|
fireEvent.change(dateInput!, { target: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => {
|
||||||
|
const row = baseRow();
|
||||||
|
const { patches } = renderDialog(row);
|
||||||
|
|
||||||
|
changeDateCompleted("2026-07-15");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
|
||||||
|
|
||||||
|
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
|
||||||
|
expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]);
|
||||||
|
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||||
|
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||||
|
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||||
|
expect(row.status).toBe("Scheduled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => {
|
||||||
|
const row = baseRow({ completedDate: "2026-07-01" });
|
||||||
|
const { patches } = renderDialog(row);
|
||||||
|
|
||||||
|
changeDateCompleted("2026-07-20");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||||
|
|
||||||
|
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ generatedAt: expect.any(String) }),
|
||||||
|
"Yes",
|
||||||
|
);
|
||||||
|
expect(patches).toEqual([{ docStatus: "Yes" }, { completedDate: "2026-07-20" }]);
|
||||||
|
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||||
|
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||||
|
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||||
|
expect(row.status).toBe("Scheduled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -75,6 +75,7 @@ const canceledRow: WorkOrderTableRow = {
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Test Dispatcher",
|
dispatcherName: "Test Dispatcher",
|
||||||
scheduledOn: "2026-07-20",
|
scheduledOn: "2026-07-20",
|
||||||
|
completedDate: "",
|
||||||
targetWeek: "2026-07-20",
|
targetWeek: "2026-07-20",
|
||||||
dayGroup: "2026-07-20",
|
dayGroup: "2026-07-20",
|
||||||
dayLabel: "Mon",
|
dayLabel: "Mon",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Alice",
|
dispatcherName: "Alice",
|
||||||
scheduledOn: "2026-06-23",
|
scheduledOn: "2026-06-23",
|
||||||
|
completedDate: "",
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
dayGroup: "06/23",
|
dayGroup: "06/23",
|
||||||
dayLabel: "TUE · 06/23",
|
dayLabel: "TUE · 06/23",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue