Merge remote-tracking branch 'origin/dev' into feature/wo-advanced-filters-parity

This commit is contained in:
Arthur Bassi 2026-08-13 16:18:51 -03:00
commit 8e61f97200
179 changed files with 11127 additions and 799 deletions

View file

@ -2,16 +2,5 @@
"version": 1,
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500,
"godfileDebt": [
{
"path": "src/app/(protected)/workorders/[id].tsx",
"maxLines": 797,
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
},
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
}
]
"godfileDebt": []
}

View file

@ -0,0 +1,19 @@
import type { ElementType } from "react";
type CompDocCoLineProps = {
icon: ElementType;
text: string;
bold?: boolean;
};
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
return (
<div
className="flex items-center justify-end gap-1.5 break-words"
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
>
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
</div>
);
}

View file

@ -0,0 +1,39 @@
import type { ElementType } from "react";
type CompDocDetailRowProps = {
icon: ElementType;
label: string;
value: string;
last?: boolean;
};
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
return (
<div
className="flex items-start gap-2.5"
style={{
padding: "10px 0",
borderBottom: last ? "none" : "1px solid var(--color-border)",
}}
>
<Icon
size={15}
className="shrink-0"
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
/>
<div className="min-w-0">
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--color-text-primary)",
marginTop: 1,
}}
>
{value}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditableRow } from "./comp-doc-editable-row";
type CompDocDetailsSectionProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string;
onDateCompletedChange: (value: string) => void;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
};
export function CompDocDetailsSection({
row,
site,
editAddress,
onAddressChange,
editDateCompleted,
onDateCompletedChange,
amazonRep,
seaRep,
isGenerated,
}: CompDocDetailsSectionProps) {
return (
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocEditableRow
label="Address"
value={editAddress}
onChange={onAddressChange}
placeholder={site.fullAddress !== "—" ? site.fullAddress : "Street address"}
disabled={isGenerated}
/>
<CompDocEditableRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
disabled={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
);
}

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,50 @@
import { Ban, Save, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterDraftProps = {
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraft({
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

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,35 @@
import { CheckCircle2, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
type CompDocDialogFooterGeneratedProps = {
generatedAt: string;
onDownloadPdf: () => void;
};
export function CompDocDialogFooterGenerated({
generatedAt,
onDownloadPdf,
}: CompDocDialogFooterGeneratedProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} /> Generated {generatedAt}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
);
}

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

View file

@ -0,0 +1,94 @@
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) {
// Exclusive footer branches (prototype: one docState at a time). A signed PDF
// on file implies Generated even if local docState has not caught up yet — never
// render Draft actions alongside Generated.
const showNotRequired = docState === "not-required";
const showGenerated =
!showNotRequired && (docState === "generated" || Boolean(signOffAttachment));
const showDraft = !showNotRequired && !showGenerated && docState === "draft";
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 = "";
}}
/>
{Boolean(showDraft && !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}
/>
)}
{showNotRequired && (
<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

@ -0,0 +1,132 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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";
import { useCompDocDialog } from "./use-comp-doc-dialog";
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const dialog = useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1024px, calc(100vw - 32px))",
maxWidth: "none",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent
dividers={false}
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
sx={{
p: 0,
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">
Completion Document — WO #{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">
<CompDocDialogHeader
info={dialog.info}
cfg={dialog.cfg}
isUploading={dialog.isUploading}
onClose={() => handleClose?.()}
/>
<CompDocDialogResourceLinks
templateUrl={dialog.templateUrl}
signOffAttachment={dialog.signOffAttachment}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={dialog.info}
site={dialog.site}
notes={dialog.notes}
generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated}
isNotRequired={dialog.isNotRequired}
/>
<CompDocSidebar
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>
<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,29 @@
import { CheckCircle2 } from "lucide-react";
import { toast } from "react-toastify";
export function showCompDocDraftSavedToast() {
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
<CheckCircle2 size={16} /> Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}

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

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

View file

@ -0,0 +1,50 @@
type CompDocNotesFieldProps = {
notes: string;
onNotesChange: (value: string) => void;
isGenerated: boolean;
};
export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
return (
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
);
}

View file

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

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

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,87 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
notes: string;
};
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}

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

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

@ -0,0 +1,65 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreview({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<CompDocPreviewDocument
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocProcedureListProps = {
proc: Procedure;
};
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{st.sub != null && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{st.sub.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
))}
</ol>
)}
</li>
))}
</ol>
);
}

View file

@ -0,0 +1,174 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditRow } from "./comp-doc-edit-row";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
docState: DocState;
generatedAt: string;
notes: string;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
onNotesChange: (value: string) => void;
onDateCompletedChange: (value: string) => void;
};
export function CompDocSidebar({
row,
site,
docState,
generatedAt,
notes,
editDateCompleted,
amazonRep,
seaRep,
isGenerated,
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
const displayState: DocState =
isGenerated && docState !== "not-required" ? "generated" : docState;
const cfg = DOC_STATE_CFG[displayState];
const StateIcon = cfg.icon;
return (
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
<div
className="flex items-start gap-3"
style={{
padding: 14,
borderRadius: 10,
background: cfg.cardBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<div
className="flex shrink-0 items-center justify-center"
style={{
width: 34,
height: 34,
borderRadius: 8,
background: cfg.cardIconBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<StateIcon size={17} style={{ color: cfg.iconColor }} />
</div>
<div className="min-w-0">
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{cfg.cardTitle}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
color: "var(--muted-foreground)",
marginTop: 3,
}}
>
{docStatusCardDescription(displayState, generatedAt)}
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
<CompDocEditRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
readOnly={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
type CompDocStatusCardProps = {
docState: DocState;
generatedAt: string;
};
export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
const cfg = DOC_STATE_CFG[docState];
const StateIcon = cfg.icon;
return (
<div
className="flex items-start gap-3"
style={{
padding: 14,
borderRadius: 10,
background: cfg.cardBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<div
className="flex shrink-0 items-center justify-center"
style={{
width: 34,
height: 34,
borderRadius: 8,
background: cfg.cardIconBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<StateIcon size={17} style={{ color: cfg.iconColor }} />
</div>
<div className="min-w-0">
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{cfg.cardTitle}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
color: "var(--muted-foreground)",
marginTop: 3,
}}
>
{docStatusCardDescription(docState, generatedAt)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
type UseCompDocDialogSeedArgs = {
open: boolean;
infoId: string | number;
infoDocStatus: CompletionDocStatus;
infoCompletedDate?: string | null;
completionDocStatus?: CompletionDocStatus | null;
data?: CompDocData;
setNotes: Dispatch<SetStateAction<string>>;
setDocState: Dispatch<SetStateAction<DocState>>;
setGeneratedAt: Dispatch<SetStateAction<string>>;
setEditDateCompleted: Dispatch<SetStateAction<string>>;
};
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
export function useCompDocDialogSeed({
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
}: UseCompDocDialogSeedArgs) {
const seedKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!open) {
seedKeyRef.current = null;
return;
}
const key = String(infoId);
if (seedKeyRef.current === key) return;
seedKeyRef.current = key;
setNotes(data?.notes ?? "");
setDocState(docStateOf(completionDocStatus || infoDocStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(infoCompletedDate || "");
}, [
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data?.notes,
data?.generatedAt,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
]);
}

View file

@ -0,0 +1,179 @@
import { 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 { 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 } from "@/domain/work-orders/utils/completion-doc-state";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
type UseCompDocDialogArgs = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<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,
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 pdfInputRef = useRef<HTMLInputElement>(null);
const [isFinalizing, setIsFinalizing] = useState(false);
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("");
useCompDocDialogSeed({
open,
infoId: info.id,
infoDocStatus: info.docStatus,
infoCompletedDate: info.completedDate,
completionDocStatus: completion?.docStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
});
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, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
useCompDocPersistFlow({
workOrderId: row.id,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate: info.completedDate || "",
onSave,
onDateCompletedChange,
onGenerated: (ts) => {
setGeneratedAt(ts);
setDocState("generated");
},
onDraftSaved: notifyDraftSaved,
});
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
});
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated: () => setDocState("generated"),
onPersistFailure: refreshAuthoritativeState,
onFinalizingChange: setIsFinalizing,
});
return {
info,
templateUrl,
signOffAttachment,
rowVersion,
isDocumentsReadOnly,
site,
notes,
setNotes,
docState,
generatedAt,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
isUploading: uploadCompletionMutation.isPending || isFinalizing,
pdfInputRef,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
handleUploadPdf,
};
}

View file

@ -0,0 +1,58 @@
import { toast } from "react-toastify";
import type {
CompDocData,
CompDocSaveStatus,
DocState,
} from "@/domain/work-orders/types/completion-doc";
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
type UseCompDocNotRequiredActionsArgs = {
docState: DocState;
setDocState: (state: DocState) => void;
persist: PersistFn;
refreshAuthoritativeState: () => void;
setIsFinalizing: (value: boolean) => void;
};
export function useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
}: UseCompDocNotRequiredActionsArgs) {
async function handleMarkNotRequired() {
const previous = docState;
setDocState("not-required");
setIsFinalizing(true);
try {
await persist({}, "NN");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(
error instanceof Error ? error.message : "Failed to mark document as not required",
);
} finally {
setIsFinalizing(false);
}
}
async function handleUndo() {
const previous = docState;
setDocState("draft");
setIsFinalizing(true);
try {
await persist({}, "No");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
} finally {
setIsFinalizing(false);
}
}
return { handleMarkNotRequired, handleUndo };
}

View file

@ -0,0 +1,98 @@
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) => Promise<void>;
syncDateIfChanged: () => Promise<void>;
setGeneratedAt: (value: string) => void;
markGenerated: () => void;
onPersistFailure?: () => void;
onFinalizingChange?: (finalizing: boolean) => void;
};
export function useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated,
onPersistFailure,
onFinalizingChange,
}: 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: () => {
// Keep close disabled across the gap between upload settle and docStatus persist.
onFinalizingChange?.(true);
void (async () => {
const ts = fmtDocNow();
try {
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
setGeneratedAt(ts);
markGenerated();
} catch (error) {
onPersistFailure?.();
toast.error(
error instanceof Error ? error.message : "Failed to finalize completion document",
);
} finally {
onFinalizingChange?.(false);
}
})();
},
},
);
}
return { handleDownloadPdf, handleUploadPdf };
}

View file

@ -0,0 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import { queryKeys } from "@/infra/query-key/query-key";
type UseCompDocPersistFlowArgs = {
workOrderId: string | number;
notes: string;
generatedAt: string;
editDateCompleted: string;
currentCompletedDate: string;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
onGenerated: (generatedAt: string) => void;
onDraftSaved: () => void;
};
export function useCompDocPersistFlow({
workOrderId,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate,
onSave,
onDateCompletedChange,
onGenerated,
onDraftSaved,
}: UseCompDocPersistFlowArgs) {
const queryClient = useQueryClient();
const refreshAuthoritativeState = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(workOrderId),
});
};
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
await onSave({ notes, generatedAt, ...extra }, status);
};
const syncDateIfChanged = async () => {
if (editDateCompleted !== currentCompletedDate) {
await onDateCompletedChange?.(editDateCompleted);
}
};
async function handleSaveDraft() {
try {
// Prerequisites first; docStatus last.
await syncDateIfChanged();
await persist({}, "No");
onDraftSaved();
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
// Prerequisites first; only mark Generated after completedDate persists.
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
onGenerated(ts);
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
return {
persist,
syncDateIfChanged,
refreshAuthoritativeState,
handleSaveDraft,
handleGenerate,
};
}

View file

@ -0,0 +1,67 @@
import { useMemo } from "react";
import { Box, Typography } from "@mui/material";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
type AuditTimelineProps = {
entries: WorkOrderAuditEntry[];
emptyMessage?: string;
maxHeightClass?: string;
};
export function AuditTimeline({
entries,
emptyMessage = "No changes recorded.",
maxHeightClass = "max-h-72",
}: AuditTimelineProps) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return <Typography color="text.secondary">{emptyMessage}</Typography>;
}
return (
<Box className={`${maxHeightClass} space-y-3 overflow-y-auto`}>
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Box className="mb-1 flex justify-between gap-2">
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Box>
<Typography variant="body2">
{entry.action ? (
entry.action
) : (
<>
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</>
)}
</Typography>
{Boolean(entry.action && entry.fieldName) && (
<Typography
variant="caption"
color="text.secondary"
className="mt-1"
sx={{ display: "block" }}
>
{entry.fieldName}
{entry.oldValue || entry.newValue
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
: ""}
</Typography>
)}
</Box>
))}
</Box>
);
}

View file

@ -0,0 +1,164 @@
import { Settings } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
getAvatarColorFromName,
getDispatcherAvatarColor,
getInitialsFromName,
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
if (entry.type === "system") return true;
if (entry.type === "manual") return false;
const name = entry.userName?.trim();
return !name || name.toLowerCase() === "system";
}
function formatClock(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
}
/**
* Manual (image): `· 9:48 AM`
* System (image): `05/13 · 8:00 AM`
* Prefer API display strings that already include · / AM|PM.
*/
function formatAuditTime(value: string, isSystem: boolean): string {
if (!value) return "";
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const timePart = formatClock(date);
if (isSystem) {
const datePart = date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return `${datePart} · ${timePart}`;
}
return `· ${timePart}`;
}
type SlideOverAuditListProps = {
entries: WorkOrderAuditEntry[];
users?: WorkOrderUser[];
};
export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
return (
<div>
<Text
as="p"
className="mb-3 text-[10px] font-medium uppercase"
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
>
Audit Log
</Text>
<div className="space-y-2.5">
{entries.length === 0 ? (
<Text
as="p"
className="py-4 text-center text-[12px]"
style={{ color: "var(--muted-foreground)" }}
>
No changes recorded
</Text>
) : (
entries.map((entry, index) => {
const isSys = isSystemEntry(entry);
const dispatcher = users.find(
(user) =>
entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
);
const name =
dispatcher?.name ||
entry.userName?.trim() ||
(entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
const color = dispatcher
? getDispatcherAvatarColor(dispatcher)
: getAvatarColorFromName(name);
const initials = dispatcher
? getInitialsFromName(dispatcher.name)
: getInitialsFromName(name);
const action = formatBoardAuditAction(entry);
return (
<div
key={String(entry.id) || index}
className="flex items-start gap-2.5"
style={
isSys
? {
background: "#F8FAFC",
borderRadius: 6,
padding: "10px",
}
: {
background: "transparent",
padding: "10px 0",
}
}
>
{isSys ? (
<div
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded"
style={{ background: "#E2E8F0" }}
>
<Settings size={10} style={{ color: "#64748B" }} />
</div>
) : (
<div
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[8px] font-bold"
style={
dispatcher || name !== "Unknown"
? { background: `${color}25`, color }
: {
background: "var(--color-bg-muted)",
color: "var(--color-text-muted)",
}
}
>
{initials}
</div>
)}
<div className="min-w-0 flex-1">
{isSys ? (
<Text as="p" className="text-[12px] leading-snug" style={{ color: "#374151" }}>
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
<span style={{ color: "#6B7280" }}> — </span>
{action}
</Text>
) : (
<Text as="p" className="text-[12px] leading-snug">
<span className="font-semibold" style={{ color: "#1E293B" }}>
{name}
</span>
<span style={{ color: "#D1D5DB" }}> · </span>
<span style={{ color: "#374151" }}>{action}</span>
</Text>
)}
<Text
as="p"
className="mt-0.5 text-[10px]"
style={{
fontFamily: "var(--font-mono)",
color: "#9CA3AF",
}}
>
{formatAuditTime(entry.createdAt, isSys)}
</Text>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
type SlideOverFooterProps = {
isCancelled: boolean;
editing: boolean;
isDirty: boolean;
showUnsaved: boolean;
saving?: boolean;
onCancelWO: () => void;
onCancelEdit: () => void;
onClose: () => void;
onEdit: () => void;
onSave: () => void;
onDiscard: () => void;
onKeepEditing: () => void;
};
export function SlideOverFooter({
isCancelled,
editing,
isDirty,
showUnsaved,
saving,
onCancelWO,
onCancelEdit,
onClose,
onEdit,
onSave,
onDiscard,
onKeepEditing,
}: SlideOverFooterProps) {
if (showUnsaved) {
return (
<div
className="flex shrink-0 items-center justify-between gap-2 px-5 py-3"
style={{
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<span
className="flex items-center gap-1.5 text-[12px]"
style={{ color: "var(--color-warning)" }}
>
<AlertTriangle size={13} />
You have unsaved changes.
</span>
<div className="flex gap-2">
<Button variant="ghost" className="text-[12px]" onClick={onDiscard}>
Discard
</Button>
<Button
variant="default"
className="gap-1.5 text-[12px]"
onClick={onSave}
loading={saving}
>
<Check size={13} />
Save
</Button>
<Button variant="ghost" className="text-[12px]" onClick={onKeepEditing}>
Keep editing
</Button>
</div>
</div>
);
}
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
return (
<div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
style={{
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<button
type="button"
onClick={onCancelWO}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--destructive)" }}
>
<AlertTriangle size={13} />
Cancel WO
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={editing ? onCancelEdit : onClose}
disabled={saving}
className="text-[12px] transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--color-text-primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--color-text-muted)";
}}
>
Cancel
</button>
{editing ? (
<Button
variant="default"
className="gap-1.5 text-[12px]"
onClick={onSave}
disabled={!isDirty}
loading={saving}
>
<Check size={13} />
Save
</Button>
) : (
<Button variant="default" className="gap-1.5 text-[12px]" onClick={onEdit}>
<Pencil size={13} />
Edit WO
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { CircularProgress } from "@mui/material";
import { X } from "lucide-react";
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 { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type SlideOverHeaderProps = {
row: WorkOrderTableRow;
isCancelled: boolean;
isFetching?: boolean;
closeDisabled?: boolean;
onClose: () => void;
};
export function SlideOverHeader({
row,
isCancelled,
isFetching = false,
closeDisabled = false,
onClose,
}: SlideOverHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Text
as="h2"
className="text-[15px] font-semibold leading-none"
style={{ color: "var(--color-text-primary)" }}
>
#{row.woNumber || "—"}
</Text>
{isCancelled && (
<span
className="rounded-full px-2 py-0.5 text-[12px] font-medium"
style={{
background: "var(--color-bg-muted)",
color: "var(--muted-foreground)",
border: "1px solid var(--color-border)",
}}
>
Cancelled
</span>
)}
{Boolean(row.site) && (
<span
className="rounded px-1.5 py-0.5 font-mono text-[11px]"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
}}
>
{row.site}
</span>
)}
{row.isPastDue ? (
<span
className="inline-flex items-center gap-1.5 text-[11px] font-medium leading-none"
style={{
background: "var(--status-pastdue-bg)",
color: "var(--status-pastdue-text)",
borderRadius: 4,
padding: "3px 8px",
}}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
</span>
) : row.status ? (
<WoTableStatusLabel status={row.status} />
) : null}
{Boolean(row.type) && <WoTableTypeLabel type={row.type as WOType} />}
</div>
<div className="flex shrink-0 items-center gap-1">
{isFetching && <CircularProgress size={14} aria-label="Refreshing details" />}
<button
type="button"
aria-label="Close"
onClick={onClose}
disabled={closeDisabled}
className="shrink-0 rounded p-1.5 transition-colors disabled:opacity-40"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
if (!closeDisabled) {
e.currentTarget.style.background = "var(--color-bg-muted)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<X size={15} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
type SlideOverInfoRowProps = {
label: string;
children: ReactNode;
};
export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
return (
<div
className="flex items-center gap-2 py-1.5"
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
>
<span className="w-28 shrink-0 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
{label}
</span>
<div className="min-w-0 flex-1 text-[12.5px]" style={{ color: "var(--color-text-primary)" }}>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
type SlideOverAuditTabProps = {
entries: WorkOrderAuditEntry[];
users?: WorkOrderUser[];
};
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
return <SlideOverAuditList entries={entries} users={users} />;
}

View file

@ -0,0 +1,265 @@
import { useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
import {
getAvatarColorFromName,
getInitialsFromName,
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
function formatCommentTime(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
const textareaStyle = {
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
} as const;
type SlideOverCommentItemProps = {
comment: WorkOrderComment;
canEdit: boolean;
isEditing: boolean;
draftText: string;
isUpdatePending: boolean;
onStartEdit: () => void;
onDraftChange: (value: string) => void;
onCancelEdit: () => void;
onSaveEdit: () => void;
};
function SlideOverCommentItem({
comment,
canEdit,
isEditing,
draftText,
isUpdatePending,
onStartEdit,
onDraftChange,
onCancelEdit,
onSaveEdit,
}: SlideOverCommentItemProps) {
const author = comment.userName || comment.commenter || "Unknown";
const color = getAvatarColorFromName(author);
const initials = getInitialsFromName(author);
return (
<div className="flex items-start gap-2.5 py-2.5">
<span
className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }}
>
{initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<span
className="truncate text-[12px] font-semibold"
style={{ color: "var(--color-text-primary)" }}
>
{author}
</span>
<span className="shrink-0 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{formatCommentTime(comment.createdDate)}
</span>
</div>
{canEdit && !isEditing && (
<button
type="button"
title="Edit comment"
aria-label="Edit comment"
onClick={onStartEdit}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<Pencil size={12} />
</button>
)}
</div>
{isEditing ? (
<div className="mt-1.5 space-y-2">
<textarea
value={draftText}
onChange={(event) => onDraftChange(event.target.value)}
rows={3}
disabled={isUpdatePending}
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
style={textareaStyle}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="default"
className="h-8 text-[12px]"
disabled={isUpdatePending}
onClick={onCancelEdit}
style={{ color: "var(--color-text-muted)" }}
>
Cancel
</Button>
<Button
size="default"
className="h-8 text-[12px]"
disabled={!draftText.trim() || isUpdatePending}
loading={isUpdatePending}
onClick={onSaveEdit}
>
Save
</Button>
</div>
</div>
) : (
<Text
as="p"
className="mt-0.5 text-[12px] leading-snug"
style={{ color: "var(--color-text-secondary)" }}
>
{comment.commenttext}
</Text>
)}
</div>
</div>
);
}
type SlideOverCommentsTabProps = {
comments: WorkOrderComment[];
isReadOnly: boolean;
isPending: boolean;
currentUserId?: string | number;
isUpdatePending?: boolean;
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
onUpdateComment?: (
commentId: string | number,
text: string,
options?: { onSuccess?: () => void },
) => void;
};
export function SlideOverCommentsTab({
comments,
isReadOnly,
isPending,
currentUserId,
isUpdatePending = false,
onSubmit,
onUpdateComment,
}: SlideOverCommentsTabProps) {
const [commentText, setCommentText] = useState("");
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
const [draftText, setDraftText] = useState("");
const handlePost = () => {
const text = commentText.trim();
if (!text || isReadOnly || isPending) return;
onSubmit(text, { onSuccess: () => setCommentText("") });
};
const startEdit = (comment: WorkOrderComment) => {
setEditingCommentId(comment.id);
setDraftText(comment.commenttext);
};
const cancelEdit = () => {
setEditingCommentId(null);
setDraftText("");
};
const saveEdit = () => {
if (editingCommentId == null || !onUpdateComment) return;
const text = draftText.trim();
if (!text || isUpdatePending) return;
onUpdateComment(editingCommentId, text, {
onSuccess: () => {
setEditingCommentId(null);
setDraftText("");
},
});
};
return (
<div className="space-y-3">
{!isReadOnly && (
<>
<textarea
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Add comment…"
rows={3}
disabled={isPending}
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
style={textareaStyle}
/>
<div className="flex justify-end">
<Button
size="default"
className="h-8 text-[12px]"
disabled={!commentText.trim() || isPending}
onClick={handlePost}
loading={isPending}
>
Post
</Button>
</div>
</>
)}
<div
style={{
borderTop: "1px solid var(--color-bg-muted)",
pointerEvents: isReadOnly ? "none" : undefined,
}}
>
{comments.length === 0 ? (
<Text
as="p"
className="py-6 text-center text-[12px]"
style={{ color: "var(--muted-foreground)" }}
>
No comments yet
</Text>
) : (
comments.map((comment) => {
const canEdit =
!isReadOnly &&
Boolean(onUpdateComment) &&
isOwnWorkOrderComment(comment, currentUserId);
const isEditing = editingCommentId === comment.id;
return (
<div
key={String(comment.id)}
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
>
<SlideOverCommentItem
comment={comment}
canEdit={canEdit}
isEditing={isEditing}
draftText={isEditing ? draftText : comment.commenttext}
isUpdatePending={isUpdatePending && isEditing}
onStartEdit={() => startEdit(comment)}
onDraftChange={setDraftText}
onCancelEdit={cancelEdit}
onSaveEdit={saveEdit}
/>
</div>
);
})
)}
</div>
</div>
);
}

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,52 @@
import { Image as ImageIcon } from "lucide-react";
import {
MediaUploader,
type MediaUploadRequest,
} 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?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
};
export function SlideOverCompletionMedia({
isReadOnly,
media,
mediaPending,
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
onRetryMedia,
onCancelMedia,
}: 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}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}
onRetry={onRetryMedia}
onCancel={onCancelMedia}
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

@ -0,0 +1,143 @@
import { useRef } from "react";
import { Download, FileText } from "lucide-react";
import { toast } from "react-toastify";
import type { MediaUploadRequest } 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";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
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";
}
type SlideOverCompletionTabProps = {
row: WorkOrderTableRow;
isReadOnly: boolean;
completion?: WorkOrderBoardCompletion;
media: MediaFile[];
mediaPending?: boolean;
completionPending?: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
onUploadCompletionPdf?: (file: File) => void;
onOpenCompletionDoc: () => void;
};
export function SlideOverCompletionTab({
row,
isReadOnly,
completion,
media,
mediaPending = false,
completionPending = false,
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
onRetryMedia,
onCancelMedia,
onUploadCompletionPdf,
onOpenCompletionDoc,
}: SlideOverCompletionTabProps) {
const pdfInputRef = useRef<HTMLInputElement>(null);
const docStatus = completion?.docStatus || row.docStatus;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
if (isMediaWO(row.type)) {
return (
<SlideOverCompletionMedia
isReadOnly={isReadOnly}
media={media}
mediaPending={mediaPending}
onUploadMedia={onUploadMedia}
onCategorizeMedia={onCategorizeMedia}
onRemoveMedia={onRemoveMedia}
onRetryMedia={onRetryMedia}
onCancelMedia={onCancelMedia}
/>
);
}
const handlePdfSelected = (files: FileList | null) => {
const file = files?.[0];
if (!file || !onUploadCompletionPdf) 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;
}
onUploadCompletionPdf(file);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<FileText size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Completion Document
</span>
</div>
{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} />
View uploaded document
</a>
)}
{isReadOnly ? (
<SlideOverCompletionReadonlyStatus
docStatus={docStatus}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
) : (
<SlideOverCompletionActions
docStatus={docStatus}
rowVersion={row.rowVersion}
completionPending={completionPending}
hasUploadHandler={Boolean(onUploadCompletionPdf)}
pdfInputRef={pdfInputRef}
onPdfSelected={handlePdfSelected}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
)}
<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,163 @@
import { useRef } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
workOrderMediaAcceptAttribute,
} 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 SlideOverExtrasTabProps = {
uploads: MediaFile[];
isReadOnly: boolean;
pending?: boolean;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
};
export function SlideOverExtrasTab({
uploads,
isReadOnly,
pending = false,
onUpload,
onRemove,
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length > 0) {
onUpload(accepted);
}
};
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)" }}>
Extra Documents
</span>
</div>
{!isReadOnly && (
<>
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.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: 100,
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)" }}
aria-busy={pending || undefined}
>
{pending ? "Uploading…" : "Upload documents, photos or videos"}
</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={workOrderMediaAcceptAttribute()}
multiple
hidden
disabled={pending}
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</>
)}
{uploads.length === 0 ? (
<Text as="p" style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
No extra documents yet.
</Text>
) : (
<div className="space-y-1">
{uploads.map((file) => {
const ItemIcon = FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
return (
<div key={file.id} className="flex items-center gap-2 py-1">
<ItemIcon
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
{showDelete && (
<button
type="button"
title="Remove"
disabled={pending}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}

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

@ -0,0 +1,64 @@
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 { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
type SlideOverInfoTabProps = {
row: WorkOrderTableRow;
draft: WorkOrderTableRow;
editing: boolean;
isCancelled: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function SlideOverInfoTab({
row,
draft,
editing,
isCancelled,
users,
sites,
vendors,
pmTypes,
allOrders,
onDraftChange,
onDuplicateFound,
}: SlideOverInfoTabProps) {
const isEditing = editing && !isCancelled;
const source = isEditing ? draft : row;
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
if (isEditing) {
return (
<SlideOverInfoTabEditView
draft={draft}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound}
/>
);
}
return (
<SlideOverInfoTabDisplayView
source={source}
isCancelled={isCancelled}
dispatcher={dispatcher}
vendors={vendors}
/>
);
}

View file

@ -0,0 +1,162 @@
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);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// 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);
setUnsavedIntent(null);
return;
}
setDraft(row);
setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
setUnsavedIntent(null);
// 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) {
setUnsavedIntent("close");
setShowUnsaved(true);
return;
}
onClose();
};
const handleCancelEdit = () => {
if (isDirty) {
setUnsavedIntent("cancel-edit");
setShowUnsaved(true);
return;
}
if (dirtyBaseline) setDraft(dirtyBaseline);
setEditing(false);
};
const handleDiscard = () => {
const intent = unsavedIntent;
discard();
setUnsavedIntent(null);
// Close only when Discard was opened from panel close — never from cancel-edit.
// Skip when uploads block close (closeDisabled).
if (intent === "close" && !closeDisabled) {
onClose();
}
};
const startEditing = () => {
if (!infoSource) return;
setDraft(infoSource);
setBaseline(infoSource);
setEditing(true);
setTab("info");
};
return {
editing,
activeDraft,
isDirty,
showUnsaved,
setShowUnsaved,
handleDraftChange,
commit,
discard,
handleDiscard,
requestClose,
handleCancelEdit,
startEditing,
};
}

View file

@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
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 type {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
};
export function useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
const completionMedia = useMemo(
() =>
uploads.filter(
(item) =>
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
item.uploadStatus === "uploading" ||
item.uploadStatus === "failed",
),
[uploads],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
),
[detailForRow?.media],
);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
hasLocalUploading ||
uploadMediaMutation.isPending ||
deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending;
const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
startUploads(requests);
};
const uploadExtraFiles = async (files: File[]) => {
setMediaBatchPending(true);
try {
for (const file of files) {
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
}
} finally {
setMediaBatchPending(false);
}
};
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
if (!workOrderVersion) return;
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
};
const removeMedia = (id: string) => {
if (isLocal(id)) {
removeLocal(id);
return;
}
if (!workOrderVersion) return;
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
};
return {
completionMedia,
extrasUploads,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
};
}

View file

@ -0,0 +1,160 @@
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,
useUpdateWorkOrderMediaCategory,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
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 categorizeMediaMutation = useUpdateWorkOrderMediaCategory(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,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
} = useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
});
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 || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
};
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,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
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,56 @@
import { Button, Menu, MenuItem } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderAssigneeMenuProps = {
assignedTo: string;
users: WorkOrderUser[];
disabled?: boolean;
onAssign: (userId: string | number, userName: string) => void;
};
export function WorkOrderAssigneeMenu({
assignedTo,
users,
disabled = false,
onAssign,
}: WorkOrderAssigneeMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<Button
size="small"
disabled={disabled}
aria-busy={disabled || undefined}
onClick={(event) => setAnchor(event.currentTarget)}
>
{disabled ? "Updating…" : assignedTo || "Unassigned"}
</Button>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
<MenuItem
disabled={disabled}
onClick={() => {
close();
onAssign("", "");
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={assignedTo === user.name}
disabled={disabled}
onClick={() => {
close();
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</>
);
}

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

@ -0,0 +1,49 @@
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
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;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
isAssignPending?: boolean;
onDispatchClick: (dispatchId: string | number) => void;
onCommentSubmit: (
text: string,
commentType: "customer" | "internal",
options?: { onSuccess?: () => void },
) => void;
isCommentPending: boolean;
};
export function WorkOrderDetailsSection({
wo,
users,
onAssign,
isAssignPending = false,
onDispatchClick,
onCommentSubmit,
isCommentPending,
}: WorkOrderDetailsSectionProps) {
return (
<>
<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,156 @@
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 { 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.isDocumentsReadOnly}
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={(requests) => {
state.uploadMediaRequests(requests);
}}
onCategorizeMedia={state.categorizeMedia}
onRemoveMedia={state.removeMedia}
onRetryMedia={state.retryUpload}
onCancelMedia={state.cancelUpload}
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.uploadExtraFiles(files);
}}
onRemove={state.removeMedia}
/>
</WorkOrderSlideOverTabPanel>
)}
</Box>
);
}

View file

@ -0,0 +1,108 @@
import { Box } from "@mui/material";
import { SideSheet } from "@/components/ui/side-sheet";
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
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;
onError?: (error: Error) => void;
};
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
onCancelWO: (row: WorkOrderTableRow) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
};
export function WorkOrderSlideOver({
row,
editMode,
users,
sites,
vendors,
pmTypes,
allOrders,
onSave,
onOpenCompletionDoc,
onCancelWO,
onDuplicateFound,
onClose,
saving,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const { infoSource, activeDraft } = state;
return (
<SideSheet
open={Boolean(row)}
onClose={state.requestClose}
closeDisabled={state.closeDisabled}
widthClass="w-[480px] max-w-[480px]"
aria-label="Work order details"
>
{row != null && infoSource != null && activeDraft != null && (
<Box className="flex h-full flex-col overflow-hidden">
<SlideOverHeader
row={infoSource}
isCancelled={state.isCancelled}
isFetching={state.isFetching && !state.isLoading}
closeDisabled={state.closeDisabled}
onClose={state.requestClose}
/>
<WorkOrderSlideOverTabNav tab={state.tab} saving={saving} onTabChange={state.setTab} />
<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={state.isCancelled}
editing={state.editing}
isDirty={state.isDirty}
showUnsaved={state.showUnsaved}
saving={saving}
onCancelWO={() => onCancelWO(infoSource)}
onCancelEdit={state.handleCancelEdit}
onClose={state.requestClose}
onEdit={state.startEditing}
onSave={state.commit}
onDiscard={state.handleDiscard}
onKeepEditing={() => state.setShowUnsaved(false)}
/>
</Box>
)}
</SideSheet>
);
}

View file

@ -0,0 +1,50 @@
import { Menu, MenuItem } from "@mui/material";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = {
status: string;
disabled?: boolean;
onStatusChange: (status: string) => void;
};
export function WorkOrderStatusMenu({
status,
disabled = false,
onStatusChange,
}: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
disabled={disabled}
aria-label="Change status"
aria-busy={disabled || undefined}
>
<WorkOrderStatusBadge status={status} />
</MenuAnchorButton>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
<MenuItem
key={option}
selected={option === status}
disabled={disabled}
onClick={() => {
close();
if (option !== status) {
onStatusChange(option);
}
}}
>
{option}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -0,0 +1,45 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type DuplicateWoNumberDialogProps = {
open: boolean;
existing: WorkOrderTableRow | null;
onDismiss: () => void;
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function DuplicateWoNumberDialog({
open,
existing,
onDismiss,
onViewExisting,
}: DuplicateWoNumberDialogProps) {
const woNumber = existing?.woNumber ?? "";
return (
<Dialog open={open} onClose={onDismiss} aria-labelledby="duplicate-wo-number-dialog-title">
<DialogTitle id="duplicate-wo-number-dialog-title">Duplicate Work Order Number</DialogTitle>
<DialogContent>
<Text variant="body">
Work order number {woNumber} is already assigned to another work order. View the existing
record or dismiss to keep editing.
</Text>
</DialogContent>
<DialogActions>
<Button onClick={onDismiss}>Dismiss</Button>
<Button
variant="contained"
disabled={existing == null}
onClick={() => {
if (existing != null) {
onViewExisting(existing);
}
}}
>
View existing
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
type: draft.type,
type: draft.type || "PM",
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,
@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
scheduleWeekOnly: draft.scheduleWeekOnly,
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
};
}

View file

@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
/** Primary service (`pm`). Required before CompDoc can open. */
pm?: string;
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => void;
@ -18,6 +21,7 @@ type CompDocCellProps = {
export function CompDocCell({
status,
woType,
pm = "",
mediaCount = 0,
onOpen,
onOpenMedia,
@ -43,6 +47,20 @@ export function CompDocCell({
return null;
}
if (!hasCompletionDocService(pm)) {
return (
<span
title="Select a service before opening the completion document"
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold"
style={{ color: "var(--color-text-muted)", opacity: 0.65 }}
aria-disabled="true"
>
<FileText size={15} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
Select service
</span>
);
}
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";

View file

@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
};
export function DateCell({
@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
onClear,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
@ -132,6 +135,21 @@ export function DateCell({
}}
/>
)}
{Boolean(onClear) && (
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="text-[11.5px] font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
× Clear
</button>
</div>
)}
</Popover>
</>
);

View file

@ -1,43 +1,30 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) {
return null;
}
if (count < 2) return null;
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
}}
>
@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) {
);
}
type WoNumberEditorProps = {
draft: string;
invalid: boolean;
isChecking: boolean;
onDraftChange: (value: string) => void;
onCommit: () => void;
onCancel: () => void;
onBlur: () => void;
};
function WoNumberEditor({
draft,
invalid,
isChecking,
onDraftChange,
onCommit,
onCancel,
onBlur,
}: WoNumberEditorProps) {
return (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(e) => onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
onBlur={onBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
);
}
async function resolveDuplicateWoNumber(
draft: string,
allOrders: WorkOrderTableRow[],
currentId: string | number,
) {
const local = findDuplicateWoNumberRow(draft, allOrders, currentId);
const remote = local ?? (await workOrdersApi.findByWoNumber(draft));
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
}
export function EditableWONumberCell({
value,
rescheduleCount,
carriedOver = 0,
q,
currentId,
allOrders,
onSave,
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
if (!editing) {
setDraft(value);
}
if (!editing) setDraft(value);
}, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => {
if (draft === value) {
if (draft === value || isChecking) {
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
return;
}
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
setEditing(false);
setIsChecking(true);
void (async () => {
try {
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
if (duplicate) {
if (onDuplicateFound) {
onDuplicateFound(duplicate);
} else {
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
setDraft(value);
return;
}
onSave(draft);
} catch {
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsChecking(false);
setEditing(false);
}
})();
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
commit();
return;
}
setEditing(false);
};
@ -137,75 +169,34 @@ export function EditableWONumberCell({
return (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
<WoNumberEditor
draft={draft}
invalid={invalid}
isChecking={isChecking}
onDraftChange={setDraft}
onCommit={commit}
onCancel={cancel}
onBlur={handleBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
) : (
<span
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>
);

View file

@ -17,6 +17,8 @@ type SiteCellProps = {
pocNotes?: string;
sites: LocationOption[];
q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -31,6 +33,7 @@ export function SiteCell({
pocNotes,
sites,
q,
createMode = false,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
@ -70,7 +73,15 @@ export function SiteCell({
)}
</span>
) : (
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
<span className="text-[12.5px] text-[var(--color-text-muted)]">
{createMode ? (
<>
Site <span style={{ color: "var(--destructive)" }}>*</span>
</>
) : (
"—"
)}
</span>
)}
<HoverChevron />
</button>
@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
createMode={createMode}
onSave={onSave}
/>
</>

View file

@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
editable?: boolean;
showErrors?: boolean;
onPocNameChange?: (value: string) => void;
onPocPhoneChange?: (value: string) => void;
onPocNotesChange?: (value: string) => void;
};
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
export function SiteDialogPocFields({
pocName,
pocPhone,
pocNotes,
editable = false,
showErrors = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
}: SiteDialogPocFieldsProps) {
const nameMissing = showErrors && !pocName.trim();
const phoneMissing = showErrors && !pocPhone.trim();
return (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Shown from site/work order records. Board edit saves site code only.
{editable
? "Auto-filled from records — edit if outdated"
: "Shown from site/work order records. Board edit saves site code only."}
</Text>
<div className="space-y-2">
<input
value={pocName}
readOnly
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<input
value={pocPhone}
readOnly
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<div>
<input
value={pocName}
readOnly={!editable}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocNameChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={pocPhone}
readOnly={!editable}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocPhoneChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
<textarea
value={pocNotes}
readOnly
readOnly={!editable}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
onChange={(e) => onPocNotesChange?.(e.target.value)}
/>
</div>
</div>

View file

@ -1,30 +1,20 @@
import { useEffect, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
import {
useSiteDialogState,
type SitePatch,
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
export type { SitePatch };
type SiteDialogProps = {
open: boolean;
@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "",
pocNotes = "",
sites,
createMode = false,
onSave,
}: SiteDialogProps) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn(locationDetail.contact ?? "");
setPp(locationDetail.phone ?? "");
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const s = useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
});
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={s.close}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -125,7 +80,7 @@ export function SiteDialog({
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect
value={locId}
value={s.locId}
placeholder="Select site …"
icon={
<MapPin
@ -139,45 +94,45 @@ export function SiteDialog({
key: String(site.id),
label: site.name,
}))}
onPick={handlePick}
onPick={s.handlePick}
/>
<Text
as="p"
variant="error"
when={Boolean(createMode && s.showErrors && s.siteMissing)}
className="mt-1 text-[11px]"
>
Site is required
</Text>
</div>
{Boolean(locId) && (
{Boolean(s.locId) && (
<SiteDialogDetailPanel
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
addressPreview={addressPreview}
fallbackLabel={selected?.name || code || "—"}
locationDetail={s.locationDetail}
locationDetailLoading={s.locationDetailLoading}
locationDetailError={s.locationDetailError}
addressPreview={s.addressPreview}
fallbackLabel={s.selected?.name || s.code || "—"}
/>
)}
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
<SiteDialogPocFields
pocName={s.pn}
pocPhone={s.pp}
pocNotes={s.notes}
editable={createMode}
showErrors={createMode && s.showErrors}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
/>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
}}
>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
Save
</Button>
</div>

View file

@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type UseSiteDialogStateArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
sites: LocationOption[];
createMode: boolean;
onSave: (patch: SitePatch) => void;
};
export function useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
}: UseSiteDialogStateArgs) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const [showErrors, setShowErrors] = useState(false);
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const siteMissing = !locId;
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || !pocMissing);
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setShowErrors(false);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
// Only autofill empty fields so typed createMode POC edits are not overwritten.
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
const siteChanged = id !== locId;
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (siteChanged) {
setPn("");
setPp("");
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const attemptSave = () => {
if (createMode && !canConfirm) {
setShowErrors(true);
return;
}
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
};
return {
locId,
pn,
pp,
notes,
showErrors,
locationDetail,
locationDetailLoading,
locationDetailError,
selected,
addressPreview,
siteMissing,
canConfirm,
code,
handlePick,
attemptSave,
setPn,
setPp,
setNotes,
close: () => onOpenChange(false),
};
}

View file

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
type InlineCreateFieldProps = {
showError?: boolean;
error?: string;
children: ReactNode;
};
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
return (
<div className="flex w-full min-w-0 flex-col gap-1">
<div
className="flex w-full min-w-0 items-center"
style={{
minHeight: 34,
borderRadius: 6,
padding: "0 8px",
background: "var(--color-bg-surface)",
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
}}
>
{children}
</div>
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
{error}
</Text>
</div>
);
}

View file

@ -16,6 +16,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
location: draft.location,
dispatcherId: draft.dispatcherId,
scheduledOn: draft.scheduledOn,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceNotes: draft.serviceNotes,

View file

@ -0,0 +1,58 @@
import { useState } from "react";
import { toast } from "react-toastify";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
type UseWoTableInlineCreateArgs = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
users: WorkOrderUser[];
sites: LocationOption[];
onDuplicateFound: (row: WorkOrderTableRow) => void;
};
export function useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound,
}: UseWoTableInlineCreateArgs) {
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
});
const handleInlineSave = () => {
void (async () => {
const draftSnapshot = tableData.draft;
const woNumber = draftSnapshot.woNumber.trim();
if (woNumber) {
setIsCheckingInlineDuplicate(true);
try {
const duplicate =
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
(await workOrdersApi.findByWoNumber(woNumber));
if (duplicate && String(duplicate.id) !== "draft") {
onDuplicateFound(duplicate);
return;
}
} catch {
toast.error("Unable to verify the work order number. Please try again.");
return;
} finally {
setIsCheckingInlineDuplicate(false);
}
}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
})();
};
return {
handleInlineSave,
saving: createMutation.isPending || isCheckingInlineDuplicate,
};
}

View file

@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = {
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function useWoTableRowHandlers({
@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const {
dragState: dragStateRef,
@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
});
},
onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id);
onOpenSlideOver(existing, false);
onDuplicateFound?.(existing);
},
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
],
);
}

View file

@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1504;
export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -0,0 +1,88 @@
import type { RefObject } from "react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { InlineCreateField } from "./inline-create-field";
type WoTableInlineIdentityCellsProps = {
draft: WorkOrderTableDraft;
sites: LocationOption[];
sitePlace?: { city: string; state: string };
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function WoTableInlineIdentityCells({
draft,
sites,
sitePlace,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineIdentityCellsProps) {
return (
<>
<td
ref={siteCellRef}
className="group/cell"
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
>
<InlineCreateField showError={siteShowError} error={siteError}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
createMode
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</InlineCreateField>
</td>
<td
ref={woCellRef}
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
>
<InlineCreateField showError={woShowError} error="Work order number is required">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
</InlineCreateField>
</td>
</>
);
}

View file

@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
type WoTableInlineRowActionsProps = {
canSave: boolean;
saving: boolean;
saveHint: string;
onSave: () => void;
onCancel: () => void;
};
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({
canSave,
saving,
saveHint,
onSave,
onCancel,
}: WoTableInlineRowActionsProps) {
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
<button
type="button"
onClick={onSave}
disabled={!canSave}
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
disabled={saving}
title={saveHint}
aria-label={saving ? "Saving work order" : "Save work order"}
className="rounded p-1 transition-colors disabled:opacity-30"
style={{ color: "var(--color-primary)" }}
style={{
color: "var(--color-primary)",
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
}}
onMouseEnter={(e) => {
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";

View file

@ -1,20 +1,23 @@
import type { RefObject } from "react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft;
@ -25,9 +28,23 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function InlineMutedDashCell() {
return (
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
);
}
export function WoTableInlineRowCells({
draft,
weekDefault,
@ -37,45 +54,32 @@ export function WoTableInlineRowCells({
vendors,
pmTypes,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
<>
<td className="group/cell" style={{ padding: "0 14px" }}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px" }}>
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
/>
</td>
<WoTableInlineIdentityCells
draft={draft}
sites={sites}
sitePlace={sitePlace}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={siteError}
woShowError={woShowError}
onPatch={onPatch}
onDuplicateFound={onDuplicateFound}
/>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
@ -98,11 +102,15 @@ export function WoTableInlineRowCells({
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
onClear={
hasSchedule
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
: undefined
}
/>
</td>
@ -170,9 +178,8 @@ export function WoTableInlineRowCells({
/>
</td>
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
<InlineMutedDashCell />
<InlineMutedDashCell />
</>
);
}

View file

@ -1,3 +1,4 @@
import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import type {
@ -5,10 +6,17 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import {
draftHasScheduleDate,
getInlineCreateMissing,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
draftDayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void;
onCancel: () => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
const dated = draftHasScheduleDate(next);
if (dated && PRE_SCHEDULE.has(next.status)) {
return { ...next, status: "Scheduled" };
}
if (!dated && next.status === "Scheduled") {
return { ...next, status: "Pending" };
}
return next;
}
export function WoTableInlineRow({
draft,
draftDayKey,
users,
sites,
sitePlace,
vendors,
pmTypes,
allRows,
saving = false,
onChange,
onSave,
onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false);
const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null);
const canSave =
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
const siteShowError = showErrors && siteCellMissing;
const woShowError = showErrors && missing.includes("woNumber");
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
const patch = (p: Partial<WorkOrderTableDraft>) =>
onChange(withScheduleStatusSync({ ...draft, ...p }));
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
const attemptSave = () => {
if (saving) {
return;
}
if (canSave) {
onSave();
return;
}
setShowErrors(true);
if (siteCellMissing) {
focusCell(siteCellRef);
} else if (missing.includes("woNumber")) {
focusCell(woCellRef);
}
};
return (
<tr
aria-busy={saving || undefined}
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
opacity: saving ? 0.7 : 1,
pointerEvents: saving ? "none" : undefined,
@ -102,14 +126,21 @@ export function WoTableInlineRow({
vendors={vendors}
pmTypes={pmTypes}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={inlineCreateSiteError(missing)}
woShowError={woShowError}
onPatch={patch}
onDuplicateFound={onDuplicateFound}
/>
<td style={{ padding: "0 4px" }}>
<WoTableInlineRowActions
canSave={canSave}
saving={saving}
onSave={onSave}
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
onSave={attemptSave}
onCancel={onCancel}
/>
</td>

View file

@ -1,7 +1,9 @@
import type { CSSProperties } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell";
@ -12,7 +14,7 @@ type WoTableRowServiceCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
search?: string;
mutationCellStyle?: React.CSSProperties;
mutationCellStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
};
@ -95,11 +97,23 @@ export function WoTableRowServiceCells({
<CompDocCell
status={row.docStatus}
woType={row.type}
pm={row.pm}
mediaCount={row.mediaCount}
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
/>
</td>
</>
);
}

View file

@ -9,11 +9,11 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head";
@ -55,10 +55,14 @@ export function WoTable({
onPatch,
}: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
const { handleInlineSave, saving } = useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound: setDuplicateRow,
});
const handlers = useWoTableRowHandlers({
@ -70,6 +74,7 @@ export function WoTable({
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,
onDuplicateFound: setDuplicateRow,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -107,12 +112,11 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={createMutation.isPending}
saving={saving}
onChange={tableData.setDraft}
onSave={() =>
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);
@ -151,21 +155,33 @@ export function WoTable({
}
return (
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
<>
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
<DuplicateWoNumberDialog
open={duplicateRow != null}
existing={duplicateRow}
onDismiss={() => setDuplicateRow(null)}
onViewExisting={(row) => {
setDuplicateRow(null);
onOpenSlideOver(row, false);
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
/>
</>
);
}

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,90 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
wizardOpen: boolean;
onWizardOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
sites: LocationOption[];
pmTypes: string[];
vendorTechs: string[];
advOpen: boolean;
onAdvOpenChange: (open: boolean) => void;
advInitial: WorkOrderAdvancedFilters;
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
onClearAdvanced: () => void;
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
};
export function WorkOrdersListOverlays({
wizardOpen,
onWizardOpenChange,
users,
sites,
pmTypes,
vendorTechs,
advOpen,
onAdvOpenChange,
advInitial,
onApplyAdvanced,
onClearAdvanced,
activeDocRow,
onDocOpenChange,
completionDocs,
onSaveCompDoc,
onDateCompletedChange,
activeMediaRow,
onMediaOpenChange,
}: WorkOrdersListOverlaysProps) {
return (
<>
<WorkOrdersAdvancedSearchSheet
open={advOpen}
onOpenChange={onAdvOpenChange}
initial={advInitial}
onApply={onApplyAdvanced}
onClearAll={onClearAdvanced}
sites={sites}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={onMediaOpenChange}
/>
)}
</>
);
}

View file

@ -0,0 +1,32 @@
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
rows: WorkOrderTableRow[];
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function WorkOrdersListWizard({
open,
onOpenChange,
users,
rows,
onViewExisting,
}: WorkOrdersListWizardProps) {
return (
<NewWoWizard
open={open}
onOpenChange={onOpenChange}
users={users}
existingRows={rows}
onViewExistingWo={(row) => {
onOpenChange(false);
onViewExisting(row);
}}
/>
);
}

View file

@ -1,32 +1,63 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { LoadingRegion } from "@/components/ui/loading-region";
import { Text } from "@/components/ui/text";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { guardDialogClose } from "@/lib/dialog-guard";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploadModal({
row,
open,
onOpenChange,
uploads,
onChange,
}: MediaUploadModalProps) {
function resolveWorkOrderVersion(
detailRowVersion: string | null | undefined,
rowVersion: string | null | undefined,
): string | null {
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
return version || null;
}
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
const requireVersion = (): string | null => {
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return null;
}
return version;
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -45,11 +76,42 @@ export function MediaUploadModal({
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
Upload photos and videos for this work order, then assign Before, After, or Extra.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
{isLoading && !detail ? (
<LoadingRegion label="Loading media">
<Stack spacing={1.5}>
<Skeleton variant="rounded" height={100} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
</Stack>
</LoadingRegion>
) : (
<MediaUploader
uploads={uploads}
onUpload={startUploads}
onRemove={(id) => {
if (isLocal(id)) {
removeLocal(id);
return;
}
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
deleteMutation.mutate({ mediaId: id, workOrderVersion });
}}
onCategorize={(id, category) => {
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
}}
onRetry={retryUpload}
onCancel={cancelUpload}
readOnly={isReadOnly}
pending={pending}
/>
)}
</div>
<div className="mt-2 flex justify-end">
@ -57,6 +119,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Close

Some files were not shown because too many files have changed in this diff Show more