refactor(work-orders): split slide-over and CompDoc for governance

Extract focused hooks and sibling components so CompDoc, slide-over,
media uploader, and list page meet maintainability ratchets.
This commit is contained in:
Arthur Bassi 2026-07-28 10:33:09 -03:00
parent ad30eb4a4d
commit 68b85940fe
53 changed files with 3222 additions and 2085 deletions

View file

@ -0,0 +1,70 @@
import type { RefObject } from "react";
import { Ban, Save, Sparkles, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterDraftActionsProps = {
rowVersion?: string | number | null;
isUploading: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraftActions({
rowVersion,
isUploading,
pdfInputRef,
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftActionsProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Upload PDF
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,62 @@
import type { RefObject } from "react";
import { CheckCircle2, Download, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterGeneratedActionsProps = {
generatedAt: string;
signOffAttachment?: string | null;
isDocumentsReadOnly: boolean;
rowVersion?: string | number | null;
isUploading: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onDownloadPdf: () => void;
};
export function CompDocDialogFooterGeneratedActions({
generatedAt,
signOffAttachment,
isDocumentsReadOnly,
rowVersion,
isUploading,
pdfInputRef,
onDownloadPdf,
}: CompDocDialogFooterGeneratedActionsProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} />{" "}
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Replace PDF
</Button>
)}
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,53 @@
import { Ban, Sparkles, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterNotRequiredActionsProps = {
isDocumentsReadOnly: boolean;
onUndo: () => void;
};
export function CompDocDialogFooterNotRequiredActions({
isDocumentsReadOnly,
onUndo,
}: CompDocDialogFooterNotRequiredActionsProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onUndo}
>
<Undo2 size={14} /> Undo
</Button>
)}
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,89 @@
import type { RefObject } from "react";
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
type CompDocDialogFooterProps = {
docState: DocState;
isDocumentsReadOnly: boolean;
rowVersion?: string | number | null;
isUploading: boolean;
signOffAttachment?: string | null;
generatedAt: string;
pdfInputRef: RefObject<HTMLInputElement | null>;
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
onUndo: () => void;
onDownloadPdf: () => void;
onUploadPdf: (files: FileList | null) => void;
};
export function CompDocDialogFooter({
docState,
isDocumentsReadOnly,
rowVersion,
isUploading,
signOffAttachment,
generatedAt,
pdfInputRef,
onMarkNotRequired,
onSaveDraft,
onGenerate,
onUndo,
onDownloadPdf,
onUploadPdf,
}: CompDocDialogFooterProps) {
const showGenerated =
(docState === "generated" || Boolean(signOffAttachment)) && docState !== "not-required";
return (
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
onUploadPdf(event.target.files);
event.target.value = "";
}}
/>
{docState === "draft" && !isDocumentsReadOnly && (
<CompDocDialogFooterDraftActions
rowVersion={rowVersion}
isUploading={isUploading}
pdfInputRef={pdfInputRef}
onMarkNotRequired={onMarkNotRequired}
onSaveDraft={onSaveDraft}
onGenerate={onGenerate}
/>
)}
{showGenerated && (
<CompDocDialogFooterGeneratedActions
generatedAt={generatedAt}
signOffAttachment={signOffAttachment}
isDocumentsReadOnly={isDocumentsReadOnly}
rowVersion={rowVersion}
isUploading={isUploading}
pdfInputRef={pdfInputRef}
onDownloadPdf={onDownloadPdf}
/>
)}
{docState === "not-required" && (
<CompDocDialogFooterNotRequiredActions
isDocumentsReadOnly={isDocumentsReadOnly}
onUndo={onUndo}
/>
)}
</div>
);
}

View file

@ -0,0 +1,96 @@
import { FileText, X } from "lucide-react";
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = {
info: WorkOrderTableRow;
cfg: CompletionDocStateConfig;
isUploading: boolean;
onClose: () => void;
};
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{info.woNumber}
</span>
{" · "}
{serviceTitle(info)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={onClose}
disabled={isUploading}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { Download, FileText } from "lucide-react";
type CompDocDialogResourceLinksProps = {
templateUrl?: string | null;
signOffAttachment?: string | null;
};
export function CompDocDialogResourceLinks({
templateUrl,
signOffAttachment,
}: CompDocDialogResourceLinksProps) {
if (!templateUrl && !signOffAttachment) return null;
return (
<div
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
style={{
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
{Boolean(templateUrl) && (
<a
href={templateUrl ?? undefined}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment ?? undefined}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
Open uploaded PDF
</a>
)}
</div>
);
}

View file

@ -1,55 +1,16 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import {
Ban,
CheckCircle2,
Download,
FileText,
Save,
Sparkles,
Undo2,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
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 {
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
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;
import { useCompDocDialog } from "./use-comp-doc-dialog";
type CompDocDialogProps = {
row: WorkOrderTableRow;
@ -70,155 +31,15 @@ export function CompDocDialog({
onStatusChange,
onDateCompletedChange,
}: CompDocDialogProps) {
const locId = String(row.locationId || "");
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const isUploading = uploadCompletionMutation.isPending;
const pdfInputRef = useRef<HTMLInputElement>(null);
const info = boardDetail?.info ?? row;
const completion = boardDetail?.completion;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, 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(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(info.scheduledOn || "");
}, [
const dialog = useCompDocDialog({
row,
open,
info.id,
info.docStatus,
info.scheduledOn,
completion?.docStatus,
data?.notes,
data?.generatedAt,
]);
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
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 !== info.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",
},
);
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
onStatusChange?.("Completed");
}
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
}
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
if (!rowVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
},
},
);
}
const handleClose = isUploading ? undefined : () => onOpenChange(false);
data,
onSave,
onStatusChange,
onDateCompletedChange,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
return (
<Dialog
@ -249,300 +70,65 @@ export function CompDocDialog({
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{info.woNumber}</DialogTitle>
<DialogTitle className="sr-only">
Completion Document — WO #{dialog.info.woNumber}
</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span
className="font-mono"
style={{ color: "var(--color-primary)", fontWeight: 600 }}
>
#{info.woNumber}
</span>
{" · "}
{serviceTitle(info)}
</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={() => {
if (!isUploading) onOpenChange(false);
}}
disabled={isUploading}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
<CompDocDialogHeader
info={dialog.info}
cfg={dialog.cfg}
isUploading={dialog.isUploading}
onClose={() => handleClose?.()}
/>
{Boolean(templateUrl || signOffAttachment) && (
<div
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
style={{
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
{Boolean(templateUrl) && (
<a
href={templateUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
Open uploaded PDF
</a>
)}
</div>
)}
<CompDocDialogResourceLinks
templateUrl={dialog.templateUrl}
signOffAttachment={dialog.signOffAttachment}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={info}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
row={dialog.info}
site={dialog.site}
notes={dialog.notes}
generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated}
isNotRequired={dialog.isNotRequired}
/>
<CompDocSidebar
row={info}
site={site}
docState={docState}
generatedAt={generatedAt}
notes={notes}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
onNotesChange={setNotes}
onDateCompletedChange={setEditDateCompleted}
row={dialog.info}
site={dialog.site}
docState={dialog.docState}
generatedAt={dialog.generatedAt}
notes={dialog.notes}
editDateCompleted={dialog.editDateCompleted}
amazonRep={dialog.amazonRep}
seaRep={dialog.seaRep}
isGenerated={dialog.isGenerated}
onNotesChange={dialog.setNotes}
onDateCompletedChange={dialog.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)",
}}
>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
handleUploadPdf(event.target.files);
event.target.value = "";
}}
/>
{docState === "draft" && !isDocumentsReadOnly && (
<>
<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
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Upload PDF
</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" || Boolean(signOffAttachment)) &&
docState !== "not-required" && (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} />{" "}
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Replace PDF
</Button>
)}
<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">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
onClick={handleUndo}
>
<Undo2 size={14} /> Undo
</Button>
)}
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
)}
</div>
<CompDocDialogFooter
docState={dialog.docState}
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
rowVersion={dialog.rowVersion}
isUploading={dialog.isUploading}
signOffAttachment={dialog.signOffAttachment}
generatedAt={dialog.generatedAt}
pdfInputRef={dialog.pdfInputRef}
onMarkNotRequired={dialog.handleMarkNotRequired}
onSaveDraft={dialog.handleSaveDraft}
onGenerate={dialog.handleGenerate}
onUndo={dialog.handleUndo}
onDownloadPdf={dialog.handleDownloadPdf}
onUploadPdf={dialog.handleUploadPdf}
/>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,51 @@
import type { CSSProperties } from "react";
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
const COMP_DOC_INPUT_STYLE: CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};
type CompDocEditRowProps = {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
readOnly?: boolean;
};
export function CompDocEditRow({
label,
value,
onChange,
type = "text",
readOnly = false,
}: CompDocEditRowProps) {
return (
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
{label}
</div>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
className={COMP_DOC_INPUT_CLS}
style={{
...COMP_DOC_INPUT_STYLE,
...(readOnly
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
);
}

View file

@ -0,0 +1,8 @@
export const COMP_DOC_FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;

View file

@ -0,0 +1,36 @@
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { CompDocCoLine } from "./comp-doc-co-line";
export function CompDocPreviewCompanyHeader() {
return (
<>
<div
className="flex flex-wrap items-start justify-between gap-y-3"
style={{ padding: "20px 0 12px" }}
>
<div className="flex items-center">
<img
src="/seahaven-logo.svg"
alt="Seahaven Industries"
style={{ height: 34, width: "auto", display: "block" }}
/>
</div>
<div className="space-y-1">
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
</div>
</div>
<div
style={{
height: 3,
borderRadius: 2,
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
}}
/>
</>
);
}

View file

@ -0,0 +1,58 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
type CompDocPreviewDocumentProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreviewDocument({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewDocumentProps) {
return (
<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" }}>
<CompDocPreviewCompanyHeader />
<CompDocPreviewServiceInfo
row={row}
site={site}
isGenerated={isGenerated}
generatedAt={generatedAt}
/>
<CompDocPreviewVendorSafety row={row} />
<CompDocPreviewProcedureNotes pm={row.pm} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { Ban } from "lucide-react";
export function CompDocPreviewNotRequiredStamp() {
return (
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
<div
className="inline-flex items-center gap-2"
style={{
marginTop: "30%",
transform: "rotate(-15deg)",
border: "3px solid var(--warning)",
color: "var(--warning)",
borderRadius: 8,
padding: "8px 18px",
fontWeight: 800,
fontSize: 22,
letterSpacing: "0.06em",
background: "rgba(255,255,255,0.65)",
}}
>
<Ban size={20} /> NOT REQUIRED
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { Text } from "@/components/ui/text";
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureNotesProps = {
pm: string;
notes: string;
};
export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
const proc = procedureFor(pm);
return (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{Boolean(notes) && (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}

View file

@ -0,0 +1,89 @@
import { CheckCircle2 } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocPreviewServiceInfoProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
isGenerated: boolean;
generatedAt: string;
};
export function CompDocPreviewServiceInfo({
row,
site,
isGenerated,
generatedAt,
}: CompDocPreviewServiceInfoProps) {
return (
<>
{isGenerated && (
<div
className="inline-flex items-center gap-1.5"
style={{
marginTop: 16,
padding: "4px 10px",
borderRadius: 9999,
background: "var(--doc-yes-bg)",
color: "var(--color-success)",
fontSize: 11.5,
fontWeight: 600,
}}
>
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
</div>
)}
<Text
as="h2"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
as="p"
className="font-mono"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
as="p"
style={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
</>
);
}

View file

@ -0,0 +1,32 @@
import { CheckCircle2, FileText } from "lucide-react";
type CompDocPreviewStatusBadgeProps = {
isGenerated: boolean;
};
export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{isGenerated ? (
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
) : (
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
</span>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocPreviewVendorSafetyProps = {
row: WorkOrderTableRow;
};
export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
return (
<>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
</>
);
}

View file

@ -1,12 +1,8 @@
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
import { CompDocCoLine } from "./comp-doc-co-line";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@ -25,8 +21,6 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
const proc = procedureFor(row.pm);
return (
<div
className="flex min-h-0 flex-col"
@ -36,28 +30,7 @@ export function CompDocPreview({
background: "var(--color-bg-muted)",
}}
>
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{isGenerated ? (
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
) : (
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
</span>
</div>
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
@ -75,216 +48,16 @@ export function CompDocPreview({
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)",
}}
/>
<CompDocPreviewDocument
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
{isGenerated && (
<div
className="inline-flex items-center gap-1.5"
style={{
marginTop: 16,
padding: "4px 10px",
borderRadius: 9999,
background: "var(--doc-yes-bg)",
color: "var(--color-success)",
fontSize: 11.5,
fontWeight: 600,
}}
>
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
</div>
)}
<Text
as="h2"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
as="p"
className="font-mono"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
as="p"
style={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{Boolean(notes) && (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{isNotRequired && (
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
<div
className="inline-flex items-center gap-2"
style={{
marginTop: "30%",
transform: "rotate(-15deg)",
border: "3px solid var(--warning)",
color: "var(--warning)",
borderRadius: 8,
padding: "8px 18px",
fontWeight: 800,
fontSize: 22,
letterSpacing: "0.06em",
background: "rgba(255,255,255,0.65)",
}}
>
<Ban size={20} /> NOT REQUIRED
</div>
</div>
)}
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
</div>
</div>
</div>

View file

@ -1,4 +1,3 @@
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";
@ -7,15 +6,7 @@ import {
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
const COMP_DOC_INPUT_STYLE: CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};
import { CompDocEditRow } from "./comp-doc-edit-row";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
@ -47,38 +38,6 @@ export function CompDocSidebar({
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"
@ -150,7 +109,13 @@ export function CompDocSidebar({
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
{editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
<CompDocEditRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
readOnly={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}

View file

@ -0,0 +1,196 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-toastify";
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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
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 { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
type UseCompDocDialogArgs = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onStatusChange?: (status: WizardWOStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
function notifyDraftSaved() {
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)",
}}
>
Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}
export function useCompDocDialog({
row,
open,
data,
onSave,
onStatusChange,
onDateCompletedChange,
}: UseCompDocDialogArgs) {
const locId = String(row.locationId || "");
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const isUploading = uploadCompletionMutation.isPending;
const pdfInputRef = useRef<HTMLInputElement>(null);
const info = boardDetail?.info ?? row;
const completion = boardDetail?.completion;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, 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(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(info.scheduledOn || "");
}, [
open,
info.id,
info.docStatus,
info.scheduledOn,
completion?.docStatus,
data?.notes,
data?.generatedAt,
]);
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
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);
};
const syncDateIfChanged = () => {
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
};
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
}
function handleUndo() {
setDocState("draft");
persist({}, "No");
}
function handleSaveDraft() {
persist({}, "No");
syncDateIfChanged();
notifyDraftSaved();
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
syncDateIfChanged();
onStatusChange?.("Completed");
}
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated: () => setDocState("generated"),
});
return {
info,
templateUrl,
signOffAttachment,
rowVersion,
isDocumentsReadOnly,
site,
notes,
setNotes,
docState,
generatedAt,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
isUploading,
pdfInputRef,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
handleUploadPdf,
};
}

View file

@ -0,0 +1,81 @@
import { toast } from "react-toastify";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
type UseCompDocPdfActionsArgs = {
info: WorkOrderTableRow;
signOffAttachment?: string | null;
rowVersion?: string;
notes: string;
generatedAt: string;
docFields: DocFields;
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => void;
syncDateIfChanged: () => void;
setGeneratedAt: (value: string) => void;
markGenerated: () => void;
};
export function useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated,
}: UseCompDocPdfActionsArgs) {
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
}
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
if (!rowVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
const ts = fmtDocNow();
setGeneratedAt(ts);
markGenerated();
persist({ generatedAt: ts }, "Yes");
syncDateIfChanged();
},
},
);
}
return { handleDownloadPdf, handleUploadPdf };
}

View file

@ -0,0 +1,92 @@
import type { RefObject } from "react";
import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverCompletionActionsProps = {
docStatus: CompletionDocStatus;
rowVersion?: string | number | null;
completionPending: boolean;
hasUploadHandler: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onPdfSelected: (files: FileList | null) => void;
onOpenCompletionDoc: () => void;
};
function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
if (docStatus === "Yes") {
return (
<>
<CheckCircle2 size={13} />
View / Edit Document
</>
);
}
if (docStatus === "NN") {
return (
<>
<Ban size={13} />
Document Not Required — Review
</>
);
}
return (
<>
<FileText size={13} />
Open completion document
</>
);
}
export function SlideOverCompletionActions({
docStatus,
rowVersion,
completionPending,
hasUploadHandler,
pdfInputRef,
onPdfSelected,
onOpenCompletionDoc,
}: SlideOverCompletionActionsProps) {
return (
<div className="space-y-2">
{hasUploadHandler && (
<>
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
disabled={!rowVersion}
loading={completionPending}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!completionPending ? <Upload size={13} /> : undefined}
>
Upload signed PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
onPdfSelected(event.target.files);
event.target.value = "";
}}
/>
{!rowVersion && (
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Waiting for work order version before upload is available.
</Text>
)}
</>
)}
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<DocStatusButtonContent docStatus={docStatus} />
</Button>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { Image as ImageIcon } from "lucide-react";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
type SlideOverCompletionMediaProps = {
isReadOnly: boolean;
media: MediaFile[];
mediaPending: boolean;
onUploadMedia?: (files: File[], category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
};
export function SlideOverCompletionMedia({
isReadOnly,
media,
mediaPending,
onUploadMedia,
onRemoveMedia,
}: SlideOverCompletionMediaProps) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Photos &amp; Videos
</span>
</div>
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
onUpload={onUploadMedia}
onRemove={onRemoveMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { CheckCircle2, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverCompletionReadonlyStatusProps = {
docStatus: CompletionDocStatus;
onOpenCompletionDoc: () => void;
};
export function SlideOverCompletionReadonlyStatus({
docStatus,
onOpenCompletionDoc,
}: SlideOverCompletionReadonlyStatusProps) {
if (docStatus === "Yes") {
return (
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<CheckCircle2 size={13} />
View Document
</Button>
);
}
return (
<div
className="flex items-center gap-2"
style={{
padding: "10px 12px",
borderRadius: 8,
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{docStatus === "NN"
? "No completion document was required for this work order."
: "No completion document was generated."}
</span>
</div>
);
}

View file

@ -0,0 +1,35 @@
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
type SlideOverCompletionSignoffsProps = {
completion?: WorkOrderBoardCompletion;
};
export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
if (!completion || completion.dispatchSignoffs.length === 0) return null;
return (
<div className="space-y-1 pt-2">
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-text-muted)",
}}
>
Dispatch sign-offs
</span>
{completion.dispatchSignoffs.map((signoff, index) => (
<div
key={`${signoff.dispatchId ?? "d"}-${index}`}
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{signoff.signoffType || "Sign-off"}
{signoff.name ? ` — ${signoff.name}` : ""}
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
</div>
))}
</div>
);
}

View file

@ -1,9 +1,6 @@
import { useRef } from "react";
import { Ban, CheckCircle2, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
import { Download, FileText } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -13,6 +10,10 @@ import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@ -50,23 +51,13 @@ export function SlideOverCompletionTab({
if (isMediaWO(row.type)) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Photos &amp; Videos
</span>
</div>
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
onUpload={onUploadMedia}
onRemove={onRemoveMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>
</div>
</div>
<SlideOverCompletionMedia
isReadOnly={isReadOnly}
media={media}
mediaPending={mediaPending}
onUploadMedia={onUploadMedia}
onRemoveMedia={onRemoveMedia}
/>
);
}
@ -120,115 +111,23 @@ export function SlideOverCompletionTab({
)}
{isReadOnly ? (
docStatus === "Yes" ? (
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<CheckCircle2 size={13} />
View Document
</Button>
) : (
<div
className="flex items-center gap-2"
style={{
padding: "10px 12px",
borderRadius: 8,
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{docStatus === "NN"
? "No completion document was required for this work order."
: "No completion document was generated."}
</span>
</div>
)
<SlideOverCompletionReadonlyStatus
docStatus={docStatus}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
) : (
<div className="space-y-2">
{Boolean(onUploadCompletionPdf) && (
<>
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
disabled={!row.rowVersion}
loading={completionPending}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!completionPending ? <Upload size={13} /> : undefined}
>
Upload signed PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
handlePdfSelected(event.target.files);
event.target.value = "";
}}
/>
{!row.rowVersion && (
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Waiting for work order version before upload is available.
</Text>
)}
</>
)}
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
{docStatus === "Yes" ? (
<>
<CheckCircle2 size={13} />
View / Edit Document
</>
) : docStatus === "NN" ? (
<>
<Ban size={13} />
Document Not Required — Review
</>
) : (
<>
<FileText size={13} />
Open completion document
</>
)}
</Button>
</div>
<SlideOverCompletionActions
docStatus={docStatus}
rowVersion={row.rowVersion}
completionPending={completionPending}
hasUploadHandler={Boolean(onUploadCompletionPdf)}
pdfInputRef={pdfInputRef}
onPdfSelected={handlePdfSelected}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
)}
{completion != null && completion.dispatchSignoffs.length > 0 && (
<div className="space-y-1 pt-2">
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-text-muted)",
}}
>
Dispatch sign-offs
</span>
{completion.dispatchSignoffs.map((signoff, index) => (
<div
key={`${signoff.dispatchId ?? "d"}-${index}`}
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{signoff.signoffType || "Sign-off"}
{signoff.name ? ` — ${signoff.name}` : ""}
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
</div>
))}
</div>
)}
<SlideOverCompletionSignoffs completion={completion} />
</div>
);
}

View file

@ -0,0 +1,44 @@
import { Image as ImageIcon } from "lucide-react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
Yes: { bg: "#F0FDF4", text: "#166534" },
No: { bg: "#FFF1F2", text: "#BE123C" },
NN: { bg: "#F3F4F6", text: "#6B7280" },
};
export function DocBadge({ row }: { row: WorkOrderTableRow }) {
if (isMediaWO(row.type)) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--color-primary)" }}
>
<ImageIcon size={14} />
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
</span>
);
}
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
const style = DOC_BADGE_STYLE[key];
const label = key === "NN" ? "NN" : key;
return (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
style={{
background: style.bg,
color: style.text,
borderRadius: 4,
}}
>
{label}
</span>
);
}

View file

@ -0,0 +1,28 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
type SlideOverInfoTabAssignedFieldProps = {
dispatcher?: WorkOrderUser;
dispatcherName?: string;
};
export function SlideOverInfoTabAssignedField({
dispatcher,
dispatcherName,
}: SlideOverInfoTabAssignedFieldProps) {
if (!dispatcher && !dispatcherName) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
{dispatcher != null && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: getDispatcherAvatarColor(dispatcher) }}
/>
)}
{dispatcher?.name || dispatcherName}
</span>
);
}

View file

@ -0,0 +1,92 @@
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
import { DocBadge } from "./slide-over-doc-badge";
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
type SlideOverInfoTabDisplayViewProps = {
source: WorkOrderTableRow;
isCancelled: boolean;
dispatcher?: WorkOrderUser;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabDisplayView({
source,
isCancelled,
dispatcher,
vendors,
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
return (
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<SlideOverInfoRow label="WO #">
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
{source.site || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
{source.type ? (
<WoTableTypeLabel type={source.type as WOType} />
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<SlideOverInfoTabAssignedField
dispatcher={dispatcher}
dispatcherName={source.dispatcherName}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<span className="text-[12.5px]">
{source.scheduleWeekOnly && source.targetWeek
? `Week of ${source.targetWeek}`
: source.scheduledOn || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<span className="text-[12.5px]">{apptDisplay}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
{source.pm ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
{source.pocName || "—"}
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
);
}

View file

@ -0,0 +1,160 @@
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { DocBadge } from "./slide-over-doc-badge";
type SlideOverInfoTabEditViewProps = {
draft: WorkOrderTableRow;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function SlideOverInfoTabEditView({
draft,
users,
sites,
vendors,
pmTypes,
allOrders,
onDraftChange,
onDuplicateFound,
}: SlideOverInfoTabEditViewProps) {
return (
<div>
<SlideOverInfoRow label="WO #">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}
onDuplicateFound={onDuplicateFound}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(patch) =>
onDraftChange({
site: patch.site,
location: patch.location,
locationId: patch.locationId,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<AssignedToCell
dispatcherId={draft.dispatcherId}
users={users}
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
onSave={(scheduledOn) =>
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
isPastDue: false,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSaveDetails={(details) =>
onDraftChange({
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<VendorCell
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<StatusCell
wo={draft}
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
onOpenSlideOver={() => {}}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={draft} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
{draft.pocName || "—"}
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
);
}

View file

@ -0,0 +1,26 @@
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabStatusFieldProps = {
source: WorkOrderTableRow;
};
export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
if (source.isPastDue) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
</span>
);
}
if (source.status) {
return <WoTableStatusLabel status={source.status} />;
}
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}

View file

@ -0,0 +1,45 @@
import { useState } from "react";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabVendorFieldProps = {
source: WorkOrderTableRow;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
const [vendorDlg, setVendorDlg] = useState(false);
if (!source.tech && !source.company) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<>
<button
type="button"
onClick={() => setVendorDlg(true)}
className="text-left transition-opacity hover:opacity-80"
>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
</div>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
)}
</button>
<VendorDialog
open={vendorDlg}
onOpenChange={setVendorDlg}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
onSave={() => setVendorDlg(false)}
/>
</>
);
}

View file

@ -1,69 +1,11 @@
import { useState } from "react";
import { Image as ImageIcon } from "lucide-react";
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType, WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
Yes: { bg: "#F0FDF4", text: "#166534" },
No: { bg: "#FFF1F2", text: "#BE123C" },
NN: { bg: "#F3F4F6", text: "#6B7280" },
};
function DocBadge({ row }: { row: WorkOrderTableRow }) {
if (isMediaWO(row.type)) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--color-primary)" }}
>
<ImageIcon size={14} />
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
</span>
);
}
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
const style = DOC_BADGE_STYLE[key];
const label = key === "NN" ? "NN" : key;
return (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
style={{
background: style.bg,
color: style.text,
borderRadius: 4,
}}
>
{label}
</span>
);
}
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
type SlideOverInfoTabProps = {
row: WorkOrderTableRow;
@ -92,242 +34,31 @@ export function SlideOverInfoTab({
onDraftChange,
onDuplicateFound,
}: SlideOverInfoTabProps) {
const [vendorDlg, setVendorDlg] = useState(false);
const source = editing && !isCancelled ? draft : row;
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
const isEditing = editing && !isCancelled;
const source = isEditing ? draft : row;
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
if (editing && !isCancelled) {
if (isEditing) {
return (
<div>
<SlideOverInfoRow label="WO #">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}
onDuplicateFound={onDuplicateFound}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(patch) =>
onDraftChange({
site: patch.site,
location: patch.location,
locationId: patch.locationId,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<AssignedToCell
dispatcherId={draft.dispatcherId}
users={users}
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
onSave={(scheduledOn) =>
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
isPastDue: false,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSaveDetails={(details) =>
onDraftChange({
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<VendorCell
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<StatusCell
wo={draft}
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
onOpenSlideOver={() => {}}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={draft} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
{draft.pocName || "—"}
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
<SlideOverInfoTabEditView
draft={draft}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound}
/>
);
}
return (
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<SlideOverInfoRow label="WO #">
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
{source.site || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
{source.type ? (
<WoTableTypeLabel type={source.type as WOType} />
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
{dispatcher || source.dispatcherName ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
{dispatcher != null && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: getDispatcherAvatarColor(dispatcher) }}
/>
)}
{dispatcher?.name || source.dispatcherName}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<span className="text-[12.5px]">
{source.scheduleWeekOnly && source.targetWeek
? `Week of ${source.targetWeek}`
: source.scheduledOn || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<span className="text-[12.5px]">{apptDisplay}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
{source.pm ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
{source.tech || source.company ? (
<button
type="button"
onClick={() => setVendorDlg(true)}
className="text-left transition-opacity hover:opacity-80"
>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
</div>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
)}
</button>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
<VendorDialog
open={vendorDlg}
onOpenChange={setVendorDlg}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
onSave={() => setVendorDlg(false)}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
{source.isPastDue ? (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
</span>
) : source.status ? (
<WoTableStatusLabel status={source.status} />
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
{source.pocName || "—"}
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
<SlideOverInfoTabDisplayView
source={source}
isCancelled={isCancelled}
dispatcher={dispatcher}
vendors={vendors}
/>
);
}

View file

@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from "react";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
import type { SlideOverTab } from "./use-work-order-slide-over";
type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
infoSource: WorkOrderTableRow | null | undefined;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void;
};
export function useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useEffect(() => {
if (!row) {
setDraft(null);
setBaseline(null);
setEditing(false);
setShowUnsaved(false);
return;
}
setDraft(row);
setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
useEffect(() => {
if (!infoSource) return;
if (!editing) {
setDraft(infoSource);
setBaseline(infoSource);
return;
}
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
setDraft((prev) => {
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
return infoSource;
});
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
}, [activeDraft, dirtyBaseline, editing]);
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
};
const commit = () => {
if (!row || !activeDraft || !dirtyBaseline) return;
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
if (Object.keys(patch).length === 0) {
setEditing(false);
setShowUnsaved(false);
return;
}
onSave(row.id, patch, {
onSuccess: () => {
setBaseline(activeDraft);
setEditing(false);
setShowUnsaved(false);
},
});
};
const discard = () => {
if (!dirtyBaseline) return;
setDraft(dirtyBaseline);
setEditing(false);
setShowUnsaved(false);
};
const requestClose = () => {
if (closeDisabled) return;
if (editing && isDirty) {
setShowUnsaved(true);
return;
}
onClose();
};
const handleCancelEdit = () => {
if (isDirty) {
setShowUnsaved(true);
return;
}
if (dirtyBaseline) setDraft(dirtyBaseline);
setEditing(false);
};
const startEditing = () => {
if (!infoSource) return;
setDraft(infoSource);
setBaseline(infoSource);
setEditing(true);
setTab("info");
};
return {
editing,
activeDraft,
isDirty,
showUnsaved,
setShowUnsaved,
handleDraftChange,
commit,
discard,
requestClose,
handleCancelEdit,
startEditing,
};
}

View file

@ -0,0 +1,56 @@
import { useMemo, useState } from "react";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
import type {
useDeleteWorkOrderMedia,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
const COMPLETION_MEDIA_CATEGORIES = new Set(["Before", "After", "Completion"]);
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
};
export function useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
}: UseSlideOverMediaStateArgs) {
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const completionMedia = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) =>
COMPLETION_MEDIA_CATEGORIES.has(item.category),
),
),
[detailForRow?.media],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
),
[detailForRow?.media],
);
const mediaPending =
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => {
await runSequentialUploads(
files,
(file) => uploadMediaMutation.mutateAsync({ file, category }),
setMediaBatchPending,
);
};
return { completionMedia, extrasUploads, mediaPending, uploadMediaFiles };
}

View file

@ -0,0 +1,122 @@
import { useState } from "react";
import { toast } from "react-toastify";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useDeleteWorkOrderMedia,
useUpdateWorkOrderComment,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
type UseWorkOrderSlideOverArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
};
export function useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
const {
data: detail,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
const { completionMedia, extrasUploads, mediaPending, uploadMediaFiles } = useSlideOverMediaState(
{
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
},
);
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const editState = useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
});
const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion;
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate({ file, workOrderVersion: version });
};
const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage =
error instanceof Error ? error.message : "Failed to load work order details.";
return {
user,
detail,
isLoading,
isFetching,
error,
refetch,
commentMutation,
updateCommentMutation,
tab,
setTab,
detailForRow,
infoSource,
isCancelled,
isDocumentsReadOnly,
completionMedia,
extrasUploads,
mediaPending,
completionPending,
closeDisabled,
uploadMediaFiles,
deleteMediaMutation,
uploadCompletionPdf,
showDetailSkeleton,
errorMessage,
...editState,
};
}

View file

@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import { AuditTimeline } from "./audit-timeline";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
type ActivityTab = "customer" | "internal" | "audit";
type WorkOrderActivitySectionProps = {
wo: WorkOrderDetail;
onCommentSubmit: (
text: string,
commentType: "customer" | "internal",
options?: { onSuccess?: () => void },
) => void;
isCommentPending: boolean;
};
export function WorkOrderActivitySection({
wo,
onCommentSubmit,
isCommentPending,
}: WorkOrderActivitySectionProps) {
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", {
onSuccess: () => setCommentText(""),
});
};
return (
<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"
className="self-end"
disabled={!commentText.trim()}
loading={isCommentPending}
loadingText="Sending…"
>
Send
</Button>
</Box>
)}
</Paper>
);
}

View file

@ -0,0 +1,20 @@
import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
type WorkOrderDescriptionAccordionProps = {
wo: WorkOrderDetail;
};
export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
);
}

View file

@ -1,32 +1,9 @@
import { useMemo, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
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 { Button } from "@/components/ui/button";
import { AuditTimeline } from "./audit-timeline";
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
type ActivityTab = "customer" | "internal" | "audit";
import { WorkOrderActivitySection } from "./work-order-activity-section";
import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
import { WorkOrderDetailsCard } from "./work-order-details-card";
import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
type WorkOrderDetailsSectionProps = {
wo: WorkOrderDetail;
@ -51,219 +28,22 @@ export function WorkOrderDetailsSection({
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", {
onSuccess: () => 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" sx={{ display: "block" }}>
Assigned To
</Typography>
<WorkOrderAssigneeMenu
assignedTo={wo.assignedTo}
users={users}
disabled={isAssignPending}
onAssign={onAssign}
/>
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
</DetailFieldGrid>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ 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 sx={{ 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 sx={{ 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 sx={{ 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 sx={{ 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"
className="self-end"
disabled={!commentText.trim()}
loading={isCommentPending}
loadingText="Sending…"
>
Send
</Button>
</Box>
)}
</Paper>
<WorkOrderDetailsCard
wo={wo}
users={users}
onAssign={onAssign}
isAssignPending={isAssignPending}
/>
<WorkOrderSimpleAccordions wo={wo} />
<WorkOrderDispatchesAccordion wo={wo} onDispatchClick={onDispatchClick} />
<WorkOrderDescriptionAccordion wo={wo} />
<WorkOrderActivitySection
wo={wo}
onCommentSubmit={onCommentSubmit}
isCommentPending={isCommentPending}
/>
</>
);
}

View file

@ -0,0 +1,45 @@
import { Box, Paper, Typography } from "@mui/material";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
type WorkOrderDetailsCardProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
isAssignPending?: boolean;
};
export function WorkOrderDetailsCard({
wo,
users,
onAssign,
isAssignPending = false,
}: WorkOrderDetailsCardProps) {
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" sx={{ display: "block" }}>
Assigned To
</Typography>
<WorkOrderAssigneeMenu
assignedTo={wo.assignedTo}
users={users}
disabled={isAssignPending}
onAssign={onAssign}
/>
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
</DetailFieldGrid>
</Paper>
);
}

View file

@ -0,0 +1,85 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
type WorkOrderDispatchesAccordionProps = {
wo: WorkOrderDetail;
onDispatchClick: (dispatchId: string | number) => void;
};
export function WorkOrderDispatchesAccordion({
wo,
onDispatchClick,
}: WorkOrderDispatchesAccordionProps) {
return (
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ 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>
);
}

View file

@ -0,0 +1,61 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
type WorkOrderSimpleAccordionsProps = {
wo: WorkOrderDetail;
};
export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) {
return (
<>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ 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 sx={{ 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 sx={{ 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>
</>
);
}

View file

@ -0,0 +1,52 @@
import type { SlideOverTab } from "./use-work-order-slide-over";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "extras", label: "Extra Docs" },
];
type WorkOrderSlideOverTabNavProps = {
tab: SlideOverTab;
saving?: boolean;
onTabChange: (tab: SlideOverTab) => void;
};
export function WorkOrderSlideOverTabNav({
tab,
saving,
onTabChange,
}: WorkOrderSlideOverTabNavProps) {
return (
<div
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
style={{ borderBottom: "1px solid var(--color-border)" }}
role="tablist"
aria-label="Work order sections"
>
{SLIDE_OVER_TABS.map((item) => {
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
disabled={Boolean(saving)}
onClick={() => onTabChange(item.id)}
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
style={{
borderBottomColor: active ? "var(--color-primary)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
fontWeight: active ? 500 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras";
type WorkOrderSlideOverTabPanelProps = {
variant: SlideOverTabSkeletonVariant;
showSkeleton: boolean;
hasError: boolean;
errorMessage: string;
onRetry: () => void;
showContent: boolean;
children: ReactNode;
};
export function WorkOrderSlideOverTabPanel({
variant,
showSkeleton,
hasError,
errorMessage,
onRetry,
showContent,
children,
}: WorkOrderSlideOverTabPanelProps) {
return (
<>
{showSkeleton && <SlideOverTabSkeleton variant={variant} />}
{hasError && <QueryErrorPanel compact message={errorMessage} onRetry={onRetry} />}
{showContent && children}
</>
);
}

View file

@ -0,0 +1,154 @@
import { Box } from "@mui/material";
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
type WorkOrderSlideOverTabsProps = {
tab: SlideOverTab;
state: SlideOverState;
saving?: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenCompletionDoc: () => void;
};
export function WorkOrderSlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
allOrders,
onDuplicateFound,
onOpenCompletionDoc,
}: WorkOrderSlideOverTabsProps) {
const hasError = Boolean(state.error) && !state.detailForRow;
const showContent = Boolean(state.detailForRow || !state.isLoading);
const { infoSource, activeDraft } = state;
return (
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && infoSource != null && activeDraft != null && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={state.editing && !saving}
isCancelled={state.isCancelled}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
/>
)}
{tab === "comments" && (
<WorkOrderSlideOverTabPanel
variant="comments"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={state.detailForRow != null}
>
{state.detailForRow != null && (
<SlideOverCommentsTab
comments={state.detailForRow.comments}
isReadOnly={state.isCancelled}
isPending={state.commentMutation.isPending}
currentUserId={state.user?.id}
isUpdatePending={state.updateCommentMutation.isPending}
onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)}
onUpdateComment={(commentId, text, options) =>
state.updateCommentMutation.mutate({ commentId, text }, options)
}
/>
)}
</WorkOrderSlideOverTabPanel>
)}
{tab === "audit" && (
<WorkOrderSlideOverTabPanel
variant="audit"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={state.detailForRow != null}
>
{state.detailForRow != null && (
<SlideOverAuditTab entries={state.detailForRow.audit} users={users} />
)}
</WorkOrderSlideOverTabPanel>
)}
{tab === "completion" && state.infoSource != null && (
<WorkOrderSlideOverTabPanel
variant="completion"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={showContent}
>
<SlideOverCompletionTab
row={state.infoSource}
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(files: File[], category: UploadableMediaCategory) => {
void state.uploadMediaFiles(files, category);
}}
onRemoveMedia={(id) => state.deleteMediaMutation.mutate(id)}
onUploadCompletionPdf={state.uploadCompletionPdf}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
</WorkOrderSlideOverTabPanel>
)}
{tab === "extras" && (
<WorkOrderSlideOverTabPanel
variant="extras"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={showContent}
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
isReadOnly={state.isDocumentsReadOnly}
pending={state.mediaPending}
onUpload={(files) => {
void state.uploadMediaFiles(files, "Extra");
}}
onRemove={(id) => state.deleteMediaMutation.mutate(id)}
/>
</WorkOrderSlideOverTabPanel>
)}
</Box>
);
}

View file

@ -1,16 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Box } from "@mui/material";
import { toast } from "react-toastify";
import { SideSheet } from "@/components/ui/side-sheet";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
import type {
LocationOption,
VendorDropdownItem,
@ -20,33 +11,9 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useDeleteWorkOrderMedia,
useUpdateWorkOrderComment,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { useAuthContext } from "@/providers/auth-context";
type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "extras", label: "Extra Docs" },
];
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
export type SlideOverSaveOptions = {
onSuccess?: () => void;
@ -84,166 +51,14 @@ export function WorkOrderSlideOver({
onClose,
saving,
}: WorkOrderSlideOverProps) {
const { user } = useAuthContext();
const workOrderId = row?.id;
const {
data: detail,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
const [mediaBatchPending, setMediaBatchPending] = useState(false);
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useEffect(() => {
if (!row) {
setDraft(null);
setBaseline(null);
setEditing(false);
setShowUnsaved(false);
return;
}
setDraft(row);
setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
useEffect(() => {
if (!infoSource) return;
if (!editing) {
setDraft(infoSource);
setBaseline(infoSource);
return;
}
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
setDraft((prev) => {
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
return infoSource;
});
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
}, [activeDraft, dirtyBaseline, editing]);
const completionMedia = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter(
(item) =>
item.category === "Before" ||
item.category === "After" ||
item.category === "Completion",
),
),
[detailForRow?.media],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
),
[detailForRow?.media],
);
const mediaPending =
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
};
const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => {
await runSequentialUploads(
files,
(file) => uploadMediaMutation.mutateAsync({ file, category }),
setMediaBatchPending,
);
};
const commit = () => {
if (!row || !activeDraft || !dirtyBaseline) return;
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
if (Object.keys(patch).length === 0) {
setEditing(false);
setShowUnsaved(false);
return;
}
onSave(row.id, patch, {
onSuccess: () => {
setBaseline(activeDraft);
setEditing(false);
setShowUnsaved(false);
},
});
};
const discard = () => {
if (!dirtyBaseline) return;
setDraft(dirtyBaseline);
setEditing(false);
setShowUnsaved(false);
};
const requestClose = () => {
if (closeDisabled) return;
if (editing && isDirty) {
setShowUnsaved(true);
return;
}
onClose();
};
const handleCancelEdit = () => {
if (isDirty) {
setShowUnsaved(true);
return;
}
if (dirtyBaseline) setDraft(dirtyBaseline);
setEditing(false);
};
const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage =
error instanceof Error ? error.message : "Failed to load work order details.";
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const { infoSource, activeDraft } = state;
return (
<SideSheet
open={Boolean(row)}
onClose={requestClose}
closeDisabled={closeDisabled}
onClose={state.requestClose}
closeDisabled={state.closeDisabled}
widthClass="w-[480px] max-w-[480px]"
aria-label="Work order details"
>
@ -251,170 +66,43 @@ export function WorkOrderSlideOver({
<Box className="flex h-full flex-col overflow-hidden">
<SlideOverHeader
row={infoSource}
isCancelled={isCancelled}
isFetching={isFetching && !isLoading}
closeDisabled={closeDisabled}
onClose={requestClose}
isCancelled={state.isCancelled}
isFetching={state.isFetching && !state.isLoading}
closeDisabled={state.closeDisabled}
onClose={state.requestClose}
/>
<div
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
style={{ borderBottom: "1px solid var(--color-border)" }}
role="tablist"
aria-label="Work order sections"
>
{SLIDE_OVER_TABS.map((item) => {
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
disabled={Boolean(saving)}
onClick={() => setTab(item.id)}
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
style={{
borderBottomColor: active ? "var(--color-primary)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
fontWeight: active ? 500 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
<WorkOrderSlideOverTabNav tab={state.tab} saving={saving} onTabChange={state.setTab} />
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={editing && !saving}
isCancelled={isCancelled}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={handleDraftChange}
onDuplicateFound={onDuplicateFound}
/>
)}
{tab === "comments" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="comments" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow != null && (
<SlideOverCommentsTab
comments={detailForRow.comments}
isReadOnly={isCancelled}
isPending={commentMutation.isPending}
currentUserId={user?.id}
isUpdatePending={updateCommentMutation.isPending}
onSubmit={(text, options) => commentMutation.mutate({ text }, options)}
onUpdateComment={(commentId, text, options) =>
updateCommentMutation.mutate({ commentId, text }, options)
}
/>
)}
</>
)}
{tab === "audit" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="audit" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow != null && (
<SlideOverAuditTab entries={detailForRow.audit} users={users} />
)}
</>
)}
{tab === "completion" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="completion" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{Boolean(detailForRow || !isLoading) && (
<SlideOverCompletionTab
row={infoSource}
isReadOnly={isDocumentsReadOnly}
completion={detailForRow?.completion}
media={completionMedia}
mediaPending={mediaPending}
completionPending={completionPending}
onUploadMedia={(files, category) => {
void uploadMediaFiles(files, category);
}}
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
onUploadCompletionPdf={(file) => {
const version = infoSource.rowVersion;
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate({
file,
workOrderVersion: version,
});
}}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
/>
)}
</>
)}
{tab === "extras" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="extras" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{Boolean(detailForRow || !isLoading) && (
<SlideOverExtrasTab
uploads={extrasUploads}
isReadOnly={isDocumentsReadOnly}
pending={mediaPending}
onUpload={(files) => {
void uploadMediaFiles(files, "Extra");
}}
onRemove={(id) => deleteMediaMutation.mutate(id)}
/>
)}
</>
)}
</Box>
<WorkOrderSlideOverTabs
tab={state.tab}
state={state}
saving={saving}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDuplicateFound={onDuplicateFound}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
/>
<SlideOverFooter
isCancelled={isCancelled}
editing={editing}
isDirty={isDirty}
showUnsaved={showUnsaved}
isCancelled={state.isCancelled}
editing={state.editing}
isDirty={state.isDirty}
showUnsaved={state.showUnsaved}
saving={saving}
onCancelWO={() => onCancelWO(infoSource)}
onCancelEdit={handleCancelEdit}
onClose={requestClose}
onEdit={() => {
const source = infoSource;
setDraft(source);
setBaseline(source);
setEditing(true);
setTab("info");
}}
onSave={commit}
onCancelEdit={state.handleCancelEdit}
onClose={state.requestClose}
onEdit={state.startEditing}
onSave={state.commit}
onDiscard={() => {
discard();
state.discard();
onClose();
}}
onKeepEditing={() => setShowUnsaved(false)}
onKeepEditing={() => state.setShowUnsaved(false)}
/>
</Box>
)}

View file

@ -0,0 +1,50 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrderCancelDialogProps = {
row: WorkOrderTableRow | null;
isPending: boolean;
onDialogClose: (() => void) | undefined;
onKeepWO: () => void;
onConfirm: () => void;
};
export function WorkOrderCancelDialog({
row,
isPending,
onDialogClose,
onKeepWO,
onConfirm,
}: WorkOrderCancelDialogProps) {
return (
<Dialog open={Boolean(row)} onClose={onDialogClose} slotProps={dialogBlurBackdropSlotProps}>
<DialogTitle>Cancel work order?</DialogTitle>
<DialogContent>
<DialogContentText>
Cancel WO #{row?.woNumber}? The record will be kept with status Canceled.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={onKeepWO} disabled={isPending}>
Keep WO
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isPending}
loadingText="Canceling…"
>
Cancel WO
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,94 @@
import type { RefObject } from "react";
import { Upload } from "lucide-react";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits";
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
category: UploadableMediaCategory;
onCategoryChange: (category: UploadableMediaCategory) => void;
showCategoryPicker: boolean;
pending: boolean;
onFilesSelected: (files: FileList | null) => void;
};
export function MediaUploaderDropzone({
inputRef,
category,
onCategoryChange,
showCategoryPicker,
pending,
onFilesSelected,
}: MediaUploaderDropzoneProps) {
return (
<>
{showCategoryPicker && (
<div className="mb-2 flex items-center gap-2">
<label
className="text-[11px]"
style={{ color: "var(--color-text-muted)" }}
htmlFor="media-upload-category"
>
Category
</label>
<select
id="media-upload-category"
value={category}
disabled={pending}
onChange={(event) => onCategoryChange(event.target.value as UploadableMediaCategory)}
className="rounded border px-2 py-1 text-[11px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
{PHOTO_CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
)}
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
disabled={pending}
onChange={(e) => {
onFilesSelected(e.target.files);
e.target.value = "";
}}
/>
</>
);
}

View file

@ -0,0 +1,96 @@
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
X,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type MediaUploaderGridItemProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
};
export function MediaUploaderGridItem({
file,
readOnly,
pending,
onRemove,
}: MediaUploaderGridItemProps) {
const TypeIcon = FILETYPE_ICON[file.fileType];
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
return (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{file.fileType === "image" ? (
<img
src={file.url}
alt={file.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : file.fileType === "video" ? (
<>
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
{showDelete && (
<button
type="button"
onClick={() => onRemove(file.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
<div className="px-1 py-1">
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.name}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
type MediaUploaderGridProps = {
uploads: MediaFile[];
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
};
export function MediaUploaderGrid({
uploads,
readOnly,
pending,
onRemove,
}: MediaUploaderGridProps) {
if (uploads.length === 0) return null;
return (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((file) => (
<MediaUploaderGridItem
key={file.id}
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
/>
))}
</div>
);
}

View file

@ -1,33 +1,15 @@
import { useRef, useState } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
type MediaUploaderProps = {
uploads: MediaFile[];
@ -88,148 +70,21 @@ export function MediaUploader({
return (
<div>
{!readOnly && (
<>
{Boolean(onUpload) && (
<div className="mb-2 flex items-center gap-2">
<label
className="text-[11px]"
style={{ color: "var(--color-text-muted)" }}
htmlFor="media-upload-category"
>
Category
</label>
<select
id="media-upload-category"
value={category}
disabled={pending}
onChange={(event) => setCategory(event.target.value as UploadableMediaCategory)}
className="rounded border px-2 py-1 text-[11px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
{PHOTO_CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
)}
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
disabled={pending}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
</>
)}
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
const showDelete = !readOnly && canDeleteWorkOrderMedia(u);
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
{showDelete && (
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
<MediaUploaderDropzone
inputRef={inputRef}
category={category}
onCategoryChange={setCategory}
showCategoryPicker={Boolean(onUpload)}
pending={pending}
onFilesSelected={addFiles}
/>
)}
<MediaUploaderGrid
uploads={uploads}
readOnly={readOnly}
pending={pending}
onRemove={remove}
/>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const activeSlideOverRow = useMemo(() => {
if (!slideOverRow) return null;
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
}, [slideOverRow, tableData.rows]);
const activeDocRow = useMemo(() => {
if (!docRow) return null;
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
}, [docRow, tableData.rows]);
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
tableData.setSelectedId(null);
};
const handleConfirmCancel = () => {
if (!confirmCancel) return;
patchField(confirmCancel.id, { status: "Canceled" });
setConfirmCancel(null);
handleCloseSlideOver();
};
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
const saveCompletionDoc = (
row: WorkOrderTableRow,
data: CompDocData,
status: CompDocSaveStatus,
) => {
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: data }));
patchField(row.id, { docStatus: status });
};
return {
wizardOpen,
setWizardOpen,
slideOverEdit,
confirmCancel,
setConfirmCancel,
docRow,
setDocRow,
mediaRow,
setMediaRow,
completionDocs,
filters,
tableData,
patchField,
isPatchPending,
users,
usersLoading,
locations,
vendors,
pmTypes,
vendorTechs,
activeSlideOverRow,
activeDocRow,
activeMediaRow,
handleOpenSlideOver,
handleCloseSlideOver,
handleConfirmCancel,
cancelDialogClose,
saveCompletionDoc,
};
}

View file

@ -1,14 +1,4 @@
import { useMemo, useState } from "react";
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
@ -18,91 +8,16 @@ import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_com
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const activeSlideOverRow = useMemo(() => {
if (!slideOverRow) return null;
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
}, [slideOverRow, tableData.rows]);
const activeDocRow = useMemo(() => {
if (!docRow) return null;
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
}, [docRow, tableData.rows]);
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
tableData.setSelectedId(null);
};
const handleConfirmCancel = () => {
if (!confirmCancel) return;
patchField(confirmCancel.id, { status: "Canceled" });
setConfirmCancel(null);
handleCloseSlideOver();
};
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -115,7 +30,7 @@ export default function WorkOrdersListPage() {
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onNewWorkOrder={() => setWizardOpen(true)}
onNewWorkOrder={() => page.setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@ -129,19 +44,19 @@ export default function WorkOrdersListPage() {
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
usersLoading={usersLoading}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
/>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
sites={locations}
users={users}
totalCount={tableData.totalCount}
sites={page.locations}
users={page.users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
@ -162,21 +77,21 @@ export default function WorkOrdersListPage() {
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={handleOpenSlideOver}
onOpenCompDoc={setDocRow}
onOpenMedia={setMediaRow}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={patchField}
onPatch={page.patchField}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
@ -184,64 +99,42 @@ export default function WorkOrdersListPage() {
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<WorkOrderSlideOver
row={activeSlideOverRow}
editMode={slideOverEdit}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => patchField(id, patch, options)}
onOpenCompletionDoc={setDocRow}
onCancelWO={setConfirmCancel}
onDuplicateFound={(existing) => handleOpenSlideOver(existing, false)}
saving={isPatchPending}
onClose={handleCloseSlideOver}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<Dialog
open={Boolean(confirmCancel)}
onClose={cancelDialogClose}
slotProps={dialogBlurBackdropSlotProps}
>
<DialogTitle>Cancel work order?</DialogTitle>
<DialogContent>
<DialogContentText>
Cancel WO #{confirmCancel?.woNumber}? The record will be kept with status Canceled.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant="outline"
onClick={() => setConfirmCancel(null)}
disabled={isPatchPending}
>
Keep WO
</Button>
<Button
variant="destructive"
onClick={handleConfirmCancel}
loading={isPatchPending}
loadingText="Canceling…"
>
Cancel WO
</Button>
</DialogActions>
</Dialog>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{activeMediaRow != null && (
{page.activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
row={page.activeMediaRow}
open={!!page.activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
if (!open) page.setMediaRow(null);
}}
/>
)}
@ -251,15 +144,12 @@ export default function WorkOrdersListPage() {
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={(open) => {
if (!open) setDocRow(null);
if (!open) page.setDocRow(null);
}}
data={completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => {
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
patchField(activeDocRow.id, { docStatus: status });
}}
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onStatusChange={(status) => page.patchField(activeDocRow.id, { status })}
onDateCompletedChange={(date) => page.patchField(activeDocRow.id, { scheduledOn: date })}
/>
)}
</Box>

View file

@ -38,7 +38,7 @@ function invalidateBoardDetail(
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
}
interface AddWorkOrderCommentInput {
export interface AddWorkOrderCommentInput {
text: string;
commentType?: "customer" | "internal";
}