mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(work-orders): add slide-over detail and completion doc UI
This commit is contained in:
parent
2dd885a499
commit
f2d7544a40
12 changed files with 1529 additions and 105 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,402 @@
|
||||||
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocFields,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||||
|
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||||
|
import {
|
||||||
|
DOC_STATE_CFG,
|
||||||
|
docStateOf,
|
||||||
|
fmtDocNow,
|
||||||
|
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
import { CompDocPreview } from "./comp-doc-preview";
|
||||||
|
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||||
|
|
||||||
|
const FOOTER_BTN_SX = {
|
||||||
|
minHeight: 28,
|
||||||
|
height: 28,
|
||||||
|
fontSize: 12,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.5,
|
||||||
|
gap: 0.75,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type CompDocDialogProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
|
||||||
|
onStatusChange?: (status: WizardWOStatus) => void;
|
||||||
|
onDateCompletedChange?: (date: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialog({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onStatusChange,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: CompDocDialogProps) {
|
||||||
|
const locId = String(row.locationId || "");
|
||||||
|
const isMockLocation = locId.startsWith("mock-");
|
||||||
|
const { data: apiLocationDetail } = useLocationDetail(
|
||||||
|
isMockLocation || !locId ? undefined : locId,
|
||||||
|
);
|
||||||
|
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||||
|
|
||||||
|
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [docState, setDocState] = useState<DocState>("draft");
|
||||||
|
const [generatedAt, setGeneratedAt] = useState("");
|
||||||
|
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(row.docStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(row.scheduledOn || "");
|
||||||
|
}, [open, row.id, row.docStatus, row.scheduledOn, data?.notes, data?.generatedAt]);
|
||||||
|
|
||||||
|
const amazonRep = row.pocName || "";
|
||||||
|
const seaRep = row.tech || "";
|
||||||
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
|
const isNotRequired = docState === "not-required";
|
||||||
|
const isGenerated = docState === "generated";
|
||||||
|
|
||||||
|
const docFields: DocFields = {
|
||||||
|
street: site.street,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
zip: site.zip,
|
||||||
|
dateCompleted: editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMarkNotRequired() {
|
||||||
|
setDocState("not-required");
|
||||||
|
persist({}, "NN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
setDocState("draft");
|
||||||
|
persist({}, "No");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveDraft() {
|
||||||
|
persist({}, "No");
|
||||||
|
if (editDateCompleted !== row.scheduledOn) {
|
||||||
|
onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
toast(
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--foreground)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} /> Draft saved
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: "bottom-center",
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
icon: false,
|
||||||
|
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||||
|
bodyClassName: "!p-0",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
persist({ generatedAt: ts }, "Yes");
|
||||||
|
if (editDateCompleted !== row.scheduledOn) {
|
||||||
|
onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
onStatusChange?.("Completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadPdf() {
|
||||||
|
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||||
|
style: {
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "min(1024px, calc(100vw - 32px))",
|
||||||
|
maxWidth: "none",
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
dividers={false}
|
||||||
|
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||||
|
<p className="sr-only">
|
||||||
|
Generate, save or mark not-required the completion document for this work order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
padding: "0 20px",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completion Document
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO{" "}
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ color: "var(--color-primary)", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
#{row.woNumber}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{serviceTitle(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillBg,
|
||||||
|
color: cfg.pillText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.pillLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
|
<CompDocPreview
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
notes={notes}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
isNotRequired={isNotRequired}
|
||||||
|
/>
|
||||||
|
<CompDocSidebar
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
docState={docState}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
notes={notes}
|
||||||
|
editDateCompleted={editDateCompleted}
|
||||||
|
amazonRep={amazonRep}
|
||||||
|
seaRep={seaRep}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
onNotesChange={setNotes}
|
||||||
|
onDateCompletedChange={setEditDateCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{docState === "draft" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
onClick={handleMarkNotRequired}
|
||||||
|
>
|
||||||
|
<Ban size={14} /> Mark as not required
|
||||||
|
</Button>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docState === "generated" && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> Generated {generatedAt}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docState === "not-required" && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
||||||
|
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
onClick={handleUndo}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} /> Undo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
disabled
|
||||||
|
title="Marked not required — undo to generate"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={FOOTER_BTN_SX}
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||||
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
|
|
||||||
|
type CompDocPreviewProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreview({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewProps) {
|
||||||
|
const proc = procedureFor(row.pm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "58%",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
padding: "0 12px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGenerated ? (
|
||||||
|
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
||||||
|
) : (
|
||||||
|
<FileText size={13} />
|
||||||
|
)}
|
||||||
|
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
style={{ padding: "0 20px 20px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative break-words"
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-start justify-between gap-y-3"
|
||||||
|
style={{ padding: "20px 0 12px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src="/seahaven-logo.svg"
|
||||||
|
alt="Seahaven Industries"
|
||||||
|
style={{ height: 34, width: "auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
||||||
|
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
||||||
|
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
||||||
|
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
||||||
|
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isGenerated && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--doc-yes-bg)",
|
||||||
|
color: "var(--color-success)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceTitle(row).toUpperCase()}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1C75BC",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO# {row.woNumber}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site ID — {row.site}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||||
|
Amazon.com Services LLC {row.site}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.street},</p>
|
||||||
|
<p style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>{site.label}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||||
|
{row.tech || "—"}
|
||||||
|
{row.company ? ` · ${row.company}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||||
|
{SAFETY_BULLETS.map((b, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: "5px 0",
|
||||||
|
color: "#161E38",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.heading}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#58595B",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.intro}
|
||||||
|
</p>
|
||||||
|
<CompDocProcedureList proc={proc} />
|
||||||
|
|
||||||
|
{notes && (
|
||||||
|
<>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNotRequired && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
marginTop: "30%",
|
||||||
|
transform: "rotate(-15deg)",
|
||||||
|
border: "3px solid var(--warning)",
|
||||||
|
color: "var(--warning)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 18px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 22,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
background: "rgba(255,255,255,0.65)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={20} /> NOT REQUIRED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocProcedureListProps = {
|
||||||
|
proc: Procedure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||||
|
return (
|
||||||
|
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||||
|
{proc.steps.map((st, i) => (
|
||||||
|
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
{st.text}
|
||||||
|
{st.sub && (
|
||||||
|
<ol
|
||||||
|
style={{
|
||||||
|
listStyle: "lower-alpha",
|
||||||
|
paddingLeft: 22,
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{st.sub.map((x, j) => (
|
||||||
|
<li key={j} style={{ margin: "2px 0" }}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Button, Menu, MenuItem } from "@mui/material";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
|
type WorkOrderAssigneeMenuProps = {
|
||||||
|
assignedTo: string;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrderAssigneeMenuProps) {
|
||||||
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="small" onClick={(event) => setAnchor(event.currentTarget)}>
|
||||||
|
{assignedTo || "Unassigned"}
|
||||||
|
</Button>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign("", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unassigned
|
||||||
|
</MenuItem>
|
||||||
|
{users.map((user) => (
|
||||||
|
<MenuItem
|
||||||
|
key={String(user.id)}
|
||||||
|
selected={assignedTo === user.name}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign(user.id, user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||||
|
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||||
|
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
||||||
|
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
|
||||||
|
import type {
|
||||||
|
WorkOrderAuditEntry,
|
||||||
|
WorkOrderDetail,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type ActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...entries].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return <Typography color="text.secondary">No changes recorded.</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="max-h-72 space-y-4 overflow-y-auto">
|
||||||
|
{sorted.map((entry) => (
|
||||||
|
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||||
|
<Box className="mb-1 flex justify-between">
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{entry.userName || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkOrderDetailsSectionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
onDispatchClick: (dispatchId: string | number) => void;
|
||||||
|
onCommentSubmit: (text: string, commentType: "customer" | "internal") => void;
|
||||||
|
isCommentPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDetailsSection({
|
||||||
|
wo,
|
||||||
|
users,
|
||||||
|
onAssign,
|
||||||
|
onDispatchClick,
|
||||||
|
onCommentSubmit,
|
||||||
|
isCommentPending,
|
||||||
|
}: WorkOrderDetailsSectionProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
|
||||||
|
const customerComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const internalComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const auditLog = wo.auditLog ?? [];
|
||||||
|
|
||||||
|
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal");
|
||||||
|
setCommentText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Details
|
||||||
|
</Typography>
|
||||||
|
<DetailFieldGrid>
|
||||||
|
<DetailField label="Internal WO" value={wo.internalWONumber} />
|
||||||
|
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||||
|
<DetailField label="Priority" value={wo.priority || "—"} />
|
||||||
|
<DetailField label="Location" value={wo.location || "—"} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Assigned To
|
||||||
|
</Typography>
|
||||||
|
<WorkOrderAssigneeMenu assignedTo={wo.assignedTo} users={users} onAssign={onAssign} />
|
||||||
|
</Box>
|
||||||
|
<DetailField label="Source" value={wo.source || "—"} />
|
||||||
|
<DetailField label="TT" value={wo.tt || "—"} />
|
||||||
|
</DetailFieldGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Classification</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<DetailFieldGrid>
|
||||||
|
<DetailField label="Problem" value={wo.problem || "—"} />
|
||||||
|
<DetailField label="Trade" value={wo.trade || "—"} />
|
||||||
|
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||||
|
</DetailFieldGrid>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Schedule</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<DetailField
|
||||||
|
label="Created"
|
||||||
|
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField label="Due Date" value={wo.dueDate || "—"} />
|
||||||
|
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||||
|
<DetailField label="Completed" value={wo.completedDate || "—"} />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Financial</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<DetailField
|
||||||
|
label="Vendor NTE"
|
||||||
|
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField label="Customer PO" value={wo.po || "—"} />
|
||||||
|
<DetailField label="Customer NTE" value="Not connected" />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
{wo.dispatches.length ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Dispatch #</TableCell>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell>NTE</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Wait</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{wo.dispatches.map((dispatch) => {
|
||||||
|
const color = waitTimeColor(dispatch.dispatchedAt);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={String(dispatch.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onDispatchClick(dispatch.id)}
|
||||||
|
>
|
||||||
|
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||||
|
<TableCell>{dispatch.vendorName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{dispatch.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.status === "Sent" ? (
|
||||||
|
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
|
||||||
|
{timeSince(dispatch.dispatchedAt)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">No dispatches yet.</Typography>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Description</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography>{wo.description || "No description provided."}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Activity
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||||
|
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||||
|
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "customer" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(customerComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(internalComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||||
|
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Type a comment..."
|
||||||
|
disabled={isCommentPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
className="self-end"
|
||||||
|
disabled={isCommentPending || !commentText.trim()}
|
||||||
|
>
|
||||||
|
{isCommentPending ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Box, Button, CircularProgress, IconButton, Tab, Tabs, Typography } from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { SideSheet } from "@/components/ui/side-sheet";
|
||||||
|
import { WorkOrderDetailsSection } from "@/app/(protected)/workorders/_components/detail/work-order-detail-sections";
|
||||||
|
import { WorkOrderStatusMenu } from "@/app/(protected)/workorders/_components/detail/work-order-status-menu";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import {
|
||||||
|
useAddWorkOrderComment,
|
||||||
|
useChangeWorkOrderAssignment,
|
||||||
|
useChangeWorkOrderStatus,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type WorkOrderSlideOverProps = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSlideOver({ row, editMode, users, onClose }: WorkOrderSlideOverProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const workOrderId = row?.id;
|
||||||
|
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
||||||
|
const statusMutation = useChangeWorkOrderStatus(workOrderId ?? "");
|
||||||
|
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId ?? "");
|
||||||
|
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
open={Boolean(row)}
|
||||||
|
onClose={onClose}
|
||||||
|
widthClass="w-full max-w-xl"
|
||||||
|
aria-label="Work order details"
|
||||||
|
>
|
||||||
|
<Box className="flex h-full flex-col">
|
||||||
|
<Box className="flex items-start justify-between border-b border-border px-4 py-3">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline" color="text.secondary">
|
||||||
|
Work Order
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
{row?.woNumber || row?.title || "—"}
|
||||||
|
</Typography>
|
||||||
|
{wo && (
|
||||||
|
<Box className="mt-1">
|
||||||
|
<WorkOrderStatusMenu
|
||||||
|
status={wo.status}
|
||||||
|
onStatusChange={(status) => statusMutation.mutate(status)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} className="border-b border-border px-2">
|
||||||
|
<Tab label="Info" />
|
||||||
|
<Tab label="Activity" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box className="flex-1 overflow-y-auto p-4">
|
||||||
|
{isLoading && (
|
||||||
|
<Box className="flex justify-center py-12">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load work order."}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{wo && tab === 0 && (
|
||||||
|
<Box className="space-y-3">
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Title:</strong> {wo.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Location:</strong> {wo.location}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Assigned:</strong> {wo.assignedTo || "Unassigned"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Due:</strong> {wo.dueDate || "—"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Scheduled:</strong> {wo.scheduledDate || "—"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Problem:</strong> {wo.problem || "—"}
|
||||||
|
</Typography>
|
||||||
|
{wo.description && (
|
||||||
|
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||||
|
{wo.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate(`/workorders/${wo.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open full page
|
||||||
|
</Button>
|
||||||
|
{editMode && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate(`/workorders/edit/${wo.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit work order
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{wo && tab === 1 && (
|
||||||
|
<WorkOrderDetailsSection
|
||||||
|
wo={wo}
|
||||||
|
users={users}
|
||||||
|
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
|
||||||
|
onDispatchClick={() => {}}
|
||||||
|
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
|
||||||
|
isCommentPending={commentMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Menu, MenuItem } from "@mui/material";
|
||||||
|
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||||
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
|
type WorkOrderStatusMenuProps = {
|
||||||
|
status: string;
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusMenuProps) {
|
||||||
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuAnchorButton onAnchor={setAnchor} open={open} aria-label="Change status">
|
||||||
|
<WorkOrderStatusBadge status={status} />
|
||||||
|
</MenuAnchorButton>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
|
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option}
|
||||||
|
selected={option === status}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
if (option !== status) {
|
||||||
|
onStatusChange(option);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,31 +15,22 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { useWorkOrdersByLocation } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import {
|
import {
|
||||||
useTaskListTemplates,
|
useTaskListTemplates,
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
interface SelectedWorkOrder {
|
|
||||||
id: string | number;
|
|
||||||
number: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispatchCreateModalProps {
|
interface DispatchCreateModalProps {
|
||||||
workOrder: WorkOrderDetail;
|
workOrder: WorkOrderDetail;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDispatched: () => void;
|
onDispatched?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DispatchCreateModal({
|
export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalProps) {
|
||||||
workOrder,
|
|
||||||
onClose,
|
|
||||||
onDispatched,
|
|
||||||
}: DispatchCreateModalProps) {
|
|
||||||
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
||||||
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
|
|
@ -48,7 +39,7 @@ export function DispatchCreateModal({
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
||||||
const [customItems, setCustomItems] = useState<string[]>([]);
|
const [customItems, setCustomItems] = useState<string[]>([]);
|
||||||
const [newCustomItem, setNewCustomItem] = useState("");
|
const [newCustomItem, setNewCustomItem] = useState("");
|
||||||
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
|
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
|
||||||
{
|
{
|
||||||
id: workOrder.id,
|
id: workOrder.id,
|
||||||
number: workOrder.internalWONumber,
|
number: workOrder.internalWONumber,
|
||||||
|
|
@ -56,6 +47,7 @@ export function DispatchCreateModal({
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [woSearch, setWoSearch] = useState("");
|
const [woSearch, setWoSearch] = useState("");
|
||||||
|
const debouncedWoSearch = useDebounce(woSearch, 300);
|
||||||
|
|
||||||
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
||||||
workOrder.trade,
|
workOrder.trade,
|
||||||
|
|
@ -64,14 +56,10 @@ export function DispatchCreateModal({
|
||||||
const { data: taskTemplates = [] } = useTaskListTemplates();
|
const { data: taskTemplates = [] } = useTaskListTemplates();
|
||||||
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
||||||
|
|
||||||
const { data: siteWorkOrdersData } = useWorkOrdersList({
|
const { data: siteWorkOrdersData } = useWorkOrdersByLocation(
|
||||||
search: "",
|
workOrder.locationId,
|
||||||
page: 1,
|
debouncedWoSearch,
|
||||||
pageSize: 500,
|
);
|
||||||
sortBy: "number",
|
|
||||||
sortDir: "asc",
|
|
||||||
locationId: workOrder.locationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const siteWorkOrders = useMemo(
|
const siteWorkOrders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -93,14 +81,8 @@ export function DispatchCreateModal({
|
||||||
() =>
|
() =>
|
||||||
siteWorkOrders
|
siteWorkOrders
|
||||||
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
!woSearch ||
|
|
||||||
item.number.includes(woSearch) ||
|
|
||||||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
|
||||||
)
|
|
||||||
.slice(0, 10),
|
.slice(0, 10),
|
||||||
[siteWorkOrders, selectedWOs, woSearch],
|
[siteWorkOrders, selectedWOs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -118,12 +100,7 @@ export function DispatchCreateModal({
|
||||||
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
||||||
customChecklistItems: customItems.length > 0 ? customItems : null,
|
customChecklistItems: customItems.length > 0 ? customItems : null,
|
||||||
},
|
},
|
||||||
{
|
{ onSuccess: () => onClose() },
|
||||||
onSuccess: () => {
|
|
||||||
onDispatched();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -138,18 +115,13 @@ export function DispatchCreateModal({
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent className="flex flex-col gap-4">
|
<DialogContent className="flex flex-col gap-4">
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" className="mb-2">
|
<Typography variant="subtitle2" className="mb-2">
|
||||||
Work Orders ({selectedWOs.length})
|
Work Orders ({selectedWOs.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack
|
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedWOs.map((wo) => (
|
{selectedWOs.map((wo) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={String(wo.id)}
|
key={String(wo.id)}
|
||||||
|
|
@ -180,13 +152,13 @@ export function DispatchCreateModal({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedWOs((prev) => [
|
setSelectedWOs((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: item.id, number: item.number, title: item.title },
|
{ id: item.id, number: item.woNumber, title: item.title },
|
||||||
]);
|
]);
|
||||||
setWoSearch("");
|
setWoSearch("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<strong>{item.number}</strong> — {item.title}
|
<strong>{item.woNumber}</strong> — {item.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
@ -208,21 +180,9 @@ export function DispatchCreateModal({
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
/>
|
/>
|
||||||
{isLoadingVendors ? (
|
{isLoadingVendors ? (
|
||||||
<Typography
|
<Typography color="text.secondary">Loading vendors...</Typography>
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loading vendors...
|
|
||||||
</Typography>
|
|
||||||
) : filteredVendors.length === 0 ? (
|
) : filteredVendors.length === 0 ? (
|
||||||
<Typography
|
<Typography color="text.secondary">No vendors found</Typography>
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No vendors found
|
|
||||||
</Typography>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
||||||
{filteredVendors.map((vendor) => (
|
{filteredVendors.map((vendor) => (
|
||||||
|
|
@ -243,24 +203,12 @@ export function DispatchCreateModal({
|
||||||
{vendor.companyName}
|
{vendor.companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vendor.tradeSpecialties && (
|
{vendor.tradeSpecialties && (
|
||||||
<Typography
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vendor.tradeSpecialties}
|
{vendor.tradeSpecialties}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{vendor.address && (
|
{vendor.address && (
|
||||||
<Typography
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vendor.address}
|
{vendor.address}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
@ -282,10 +230,8 @@ export function DispatchCreateModal({
|
||||||
type="number"
|
type="number"
|
||||||
value={nteAmount}
|
value={nteAmount}
|
||||||
onChange={(event) => setNteAmount(event.target.value)}
|
onChange={(event) => setNteAmount(event.target.value)}
|
||||||
|
inputProps={{ step: "0.01", min: 0 }}
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: "0.01", min: 0 },
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Vendor Scheduled Date"
|
label="Vendor Scheduled Date"
|
||||||
|
|
@ -313,13 +259,7 @@ export function DispatchCreateModal({
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
{customItems.length > 0 && (
|
{customItems.length > 0 && (
|
||||||
<Stack
|
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{customItems.map((item, index) => (
|
{customItems.map((item, index) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={`${item}-${index}`}
|
key={`${item}-${index}`}
|
||||||
|
|
@ -354,6 +294,7 @@ export function DispatchCreateModal({
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
|
@ -67,7 +67,7 @@ export default function WorkOrderFormPage() {
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<WorkOrderFormSchemaValues>({
|
} = useForm<WorkOrderFormSchemaValues>({
|
||||||
resolver: zodResolver(workOrderFormSchema),
|
resolver: zodResolver(workOrderFormSchema),
|
||||||
defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
|
defaultValues: createDefaultWorkOrderFormValues(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tradeValue = useWatch({ control, name: "trade" });
|
const tradeValue = useWatch({ control, name: "trade" });
|
||||||
|
|
@ -80,7 +80,7 @@ export default function WorkOrderFormPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit && detail) {
|
if (isEdit && detail) {
|
||||||
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
reset(mapWorkOrderDetailToFormValues(detail));
|
||||||
}
|
}
|
||||||
}, [detail, isEdit, reset]);
|
}, [detail, isEdit, reset]);
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default function WorkOrderFormPage() {
|
||||||
}
|
}
|
||||||
}, [tradeValue, setValue]);
|
}, [tradeValue, setValue]);
|
||||||
|
|
||||||
const locationOptions = useMemo(() => locations, [locations]);
|
const locationOptions: LocationOption[] = locations;
|
||||||
|
|
||||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
const isLoading = isEdit && isLoadingDetail;
|
const isLoading = isEdit && isLoadingDetail;
|
||||||
|
|
@ -131,17 +131,13 @@ export default function WorkOrderFormPage() {
|
||||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||||
Work Orders
|
Work Orders
|
||||||
</Link>
|
</Link>
|
||||||
<Typography
|
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
|
||||||
sx={{
|
|
||||||
color: "text.primary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEdit ? "Edit" : "Create"}
|
|
||||||
</Typography>
|
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Paper variant="outlined" className="p-6">
|
<Paper variant="outlined" className="p-6">
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -319,10 +315,8 @@ export default function WorkOrderFormPage() {
|
||||||
{...register("vendorNTE")}
|
{...register("vendorNTE")}
|
||||||
label="Vendor NTE"
|
label="Vendor NTE"
|
||||||
type="number"
|
type="number"
|
||||||
|
inputProps={{ step: "0.01", min: 0 }}
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
|
||||||
htmlInput: { step: "0.01", min: 0 },
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -346,13 +340,7 @@ export default function WorkOrderFormPage() {
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue