mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 10:53:13 +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,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { useWorkOrdersByLocation } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useTaskListTemplates,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
interface SelectedWorkOrder {
|
||||
id: string | number;
|
||||
number: string;
|
||||
title: string;
|
||||
}
|
||||
import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
interface DispatchCreateModalProps {
|
||||
workOrder: WorkOrderDetail;
|
||||
onClose: () => void;
|
||||
onDispatched: () => void;
|
||||
onDispatched?: () => void;
|
||||
}
|
||||
|
||||
export function DispatchCreateModal({
|
||||
workOrder,
|
||||
onClose,
|
||||
onDispatched,
|
||||
}: DispatchCreateModalProps) {
|
||||
export function DispatchCreateModal({ workOrder, onClose }: DispatchCreateModalProps) {
|
||||
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
||||
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
|
|
@ -48,7 +39,7 @@ export function DispatchCreateModal({
|
|||
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
||||
const [customItems, setCustomItems] = useState<string[]>([]);
|
||||
const [newCustomItem, setNewCustomItem] = useState("");
|
||||
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
|
||||
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrderRef[]>([
|
||||
{
|
||||
id: workOrder.id,
|
||||
number: workOrder.internalWONumber,
|
||||
|
|
@ -56,6 +47,7 @@ export function DispatchCreateModal({
|
|||
},
|
||||
]);
|
||||
const [woSearch, setWoSearch] = useState("");
|
||||
const debouncedWoSearch = useDebounce(woSearch, 300);
|
||||
|
||||
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
||||
workOrder.trade,
|
||||
|
|
@ -64,14 +56,10 @@ export function DispatchCreateModal({
|
|||
const { data: taskTemplates = [] } = useTaskListTemplates();
|
||||
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
||||
|
||||
const { data: siteWorkOrdersData } = useWorkOrdersList({
|
||||
search: "",
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortBy: "number",
|
||||
sortDir: "asc",
|
||||
locationId: workOrder.locationId,
|
||||
});
|
||||
const { data: siteWorkOrdersData } = useWorkOrdersByLocation(
|
||||
workOrder.locationId,
|
||||
debouncedWoSearch,
|
||||
);
|
||||
|
||||
const siteWorkOrders = useMemo(
|
||||
() =>
|
||||
|
|
@ -93,14 +81,8 @@ export function DispatchCreateModal({
|
|||
() =>
|
||||
siteWorkOrders
|
||||
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
||||
.filter(
|
||||
(item) =>
|
||||
!woSearch ||
|
||||
item.number.includes(woSearch) ||
|
||||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
||||
)
|
||||
.slice(0, 10),
|
||||
[siteWorkOrders, selectedWOs, woSearch],
|
||||
[siteWorkOrders, selectedWOs],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
|
|
@ -118,12 +100,7 @@ export function DispatchCreateModal({
|
|||
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
||||
customChecklistItems: customItems.length > 0 ? customItems : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onDispatched();
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => onClose() },
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -138,18 +115,13 @@ export function DispatchCreateModal({
|
|||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-4">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-2">
|
||||
Work Orders ({selectedWOs.length})
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{selectedWOs.map((wo) => (
|
||||
<Chip
|
||||
key={String(wo.id)}
|
||||
|
|
@ -180,13 +152,13 @@ export function DispatchCreateModal({
|
|||
onClick={() => {
|
||||
setSelectedWOs((prev) => [
|
||||
...prev,
|
||||
{ id: item.id, number: item.number, title: item.title },
|
||||
{ id: item.id, number: item.woNumber, title: item.title },
|
||||
]);
|
||||
setWoSearch("");
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>{item.number}</strong> — {item.title}
|
||||
<strong>{item.woNumber}</strong> — {item.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -208,21 +180,9 @@ export function DispatchCreateModal({
|
|||
className="mb-2"
|
||||
/>
|
||||
{isLoadingVendors ? (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Loading vendors...
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Loading vendors...</Typography>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No vendors found
|
||||
</Typography>
|
||||
<Typography color="text.secondary">No vendors found</Typography>
|
||||
) : (
|
||||
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
||||
{filteredVendors.map((vendor) => (
|
||||
|
|
@ -243,24 +203,12 @@ export function DispatchCreateModal({
|
|||
{vendor.companyName}
|
||||
</Typography>
|
||||
{vendor.tradeSpecialties && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{vendor.tradeSpecialties}
|
||||
</Typography>
|
||||
)}
|
||||
{vendor.address && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{vendor.address}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -282,10 +230,8 @@ export function DispatchCreateModal({
|
|||
type="number"
|
||||
value={nteAmount}
|
||||
onChange={(event) => setNteAmount(event.target.value)}
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01", min: 0 },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Vendor Scheduled Date"
|
||||
|
|
@ -313,13 +259,7 @@ export function DispatchCreateModal({
|
|||
</TextField>
|
||||
|
||||
{customItems.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
||||
{customItems.map((item, index) => (
|
||||
<Chip
|
||||
key={`${item}-${index}`}
|
||||
|
|
@ -354,6 +294,7 @@ export function DispatchCreateModal({
|
|||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
|
|
@ -67,7 +67,7 @@ export default function WorkOrderFormPage() {
|
|||
formState: { errors },
|
||||
} = useForm<WorkOrderFormSchemaValues>({
|
||||
resolver: zodResolver(workOrderFormSchema),
|
||||
defaultValues: createDefaultWorkOrderFormValues() as WorkOrderFormSchemaValues,
|
||||
defaultValues: createDefaultWorkOrderFormValues(),
|
||||
});
|
||||
|
||||
const tradeValue = useWatch({ control, name: "trade" });
|
||||
|
|
@ -80,7 +80,7 @@ export default function WorkOrderFormPage() {
|
|||
|
||||
useEffect(() => {
|
||||
if (isEdit && detail) {
|
||||
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
|
||||
reset(mapWorkOrderDetailToFormValues(detail));
|
||||
}
|
||||
}, [detail, isEdit, reset]);
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ export default function WorkOrderFormPage() {
|
|||
}
|
||||
}, [tradeValue, setValue]);
|
||||
|
||||
const locationOptions = useMemo(() => locations, [locations]);
|
||||
const locationOptions: LocationOption[] = locations;
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const isLoading = isEdit && isLoadingDetail;
|
||||
|
|
@ -131,17 +131,13 @@ export default function WorkOrderFormPage() {
|
|||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{isEdit ? "Edit" : "Create"}
|
||||
</Typography>
|
||||
<Typography color="text.primary">{isEdit ? "Edit" : "Create"}</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||
<Box>
|
||||
|
|
@ -319,10 +315,8 @@ export default function WorkOrderFormPage() {
|
|||
{...register("vendorNTE")}
|
||||
label="Vendor NTE"
|
||||
type="number"
|
||||
inputProps={{ step: "0.01", min: 0 }}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01", min: 0 },
|
||||
}}
|
||||
/>
|
||||
<TextField {...register("po")} label="Customer PO" fullWidth />
|
||||
<TextField
|
||||
|
|
@ -346,13 +340,7 @@ export default function WorkOrderFormPage() {
|
|||
/>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue