Merge branch 'dev' into fix/SH-109-inline-save-unscheduled

This commit is contained in:
Alexandre Brandizzi 2026-08-12 12:11:17 -03:00 • committed by GitHub
commit 5419edc4af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 5679 additions and 464 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,70 @@
import type { RefObject } from "react";
import { Ban, Save, Sparkles, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterDraftActionsProps = {
rowVersion?: string | number | null;
isUploading: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraftActions({
rowVersion,
isUploading,
pdfInputRef,
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftActionsProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Upload PDF
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

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

View file

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

View file

@ -1,27 +1,48 @@
import type { RefObject } from "react";
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
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;
onUndo: () => 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,
onUndo,
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"
@ -31,17 +52,43 @@ export function CompDocDialogFooter({
background: "var(--color-bg-surface)",
}}
>
{docState === "draft" && (
<CompDocDialogFooterDraft
<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}
/>
)}
{docState === "generated" && (
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
{showGenerated && (
<CompDocDialogFooterGeneratedActions
generatedAt={generatedAt}
signOffAttachment={signOffAttachment}
isDocumentsReadOnly={isDocumentsReadOnly}
rowVersion={rowVersion}
isUploading={isUploading}
pdfInputRef={pdfInputRef}
onDownloadPdf={onDownloadPdf}
/>
)}
{showNotRequired && (
<CompDocDialogFooterNotRequiredActions
isDocumentsReadOnly={isDocumentsReadOnly}
onUndo={onUndo}
/>
)}
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
</div>
);
}

View file

@ -4,12 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = {
row: WorkOrderTableRow;
info: WorkOrderTableRow;
cfg: CompletionDocStateConfig;
isUploading: boolean;
onClose: () => void;
};
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
@ -44,10 +45,10 @@ export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderPr
>
WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{row.woNumber}
#{info.woNumber}
</span>
{" · "}
{serviceTitle(row)}
{serviceTitle(info)}
</div>
</div>
</div>
@ -77,8 +78,9 @@ export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderPr
<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)]"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,

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

@ -4,11 +4,12 @@ import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdro
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 { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
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;
@ -27,32 +28,19 @@ export function CompDocDialog({
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const {
site,
notes,
setNotes,
docState,
generatedAt,
editAddress,
setEditAddress,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
} = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
const dialog = useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -78,48 +66,64 @@ export function CompDocDialog({
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
<DialogTitle className="sr-only">
Completion Document — WO #{dialog.info.woNumber}
</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<CompDocDialogHeader row={row} cfg={cfg} onClose={() => onOpenChange(false)} />
<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={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
row={dialog.info}
site={dialog.site}
notes={dialog.notes}
generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated}
isNotRequired={dialog.isNotRequired}
/>
<CompDocSidebar
row={row}
site={site}
docState={docState}
generatedAt={generatedAt}
notes={notes}
editAddress={editAddress}
onAddressChange={setEditAddress}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
onNotesChange={setNotes}
onDateCompletedChange={setEditDateCompleted}
row={dialog.info}
site={dialog.site}
docState={dialog.docState}
generatedAt={dialog.generatedAt}
notes={dialog.notes}
editDateCompleted={dialog.editDateCompleted}
amazonRep={dialog.amazonRep}
seaRep={dialog.seaRep}
isGenerated={dialog.isGenerated}
onNotesChange={dialog.setNotes}
onDateCompletedChange={dialog.setEditDateCompleted}
/>
</div>
<CompDocDialogFooter
docState={docState}
generatedAt={generatedAt}
onMarkNotRequired={handleMarkNotRequired}
onUndo={handleUndo}
onSaveDraft={handleSaveDraft}
onGenerate={handleGenerate}
onDownloadPdf={handleDownloadPdf}
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
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;
@ -32,7 +30,7 @@ export function CompDocPreview({
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewBadge isGenerated={isGenerated} />
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
@ -50,38 +48,16 @@ export function CompDocPreview({
wordBreak: "break-word",
}}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
<CompDocPreviewDocument
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
{isNotRequired && <CompDocNotRequiredStamp />}
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
</div>
</div>
</div>

View file

@ -7,29 +7,26 @@ type CompDocProcedureListProps = {
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => {
const subSteps = st.sub ?? [];
return (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{subSteps.length > 0 && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{subSteps.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
))}
</ol>
)}
</li>
);
})}
{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

@ -1,8 +1,12 @@
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 { CompDocDetailsSection } from "./comp-doc-details-section";
import { CompDocNotesField } from "./comp-doc-notes-field";
import { CompDocStatusCard } from "./comp-doc-status-card";
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;
@ -10,8 +14,6 @@ type CompDocSidebarProps = {
docState: DocState;
generatedAt: string;
notes: string;
editAddress: string;
onAddressChange: (value: string) => void;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
@ -26,8 +28,6 @@ export function CompDocSidebar({
docState,
generatedAt,
notes,
editAddress,
onAddressChange,
editDateCompleted,
amazonRep,
seaRep,
@ -35,25 +35,139 @@ export function CompDocSidebar({
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 }}>
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
<CompDocDetailsSection
row={row}
site={site}
editAddress={editAddress}
onAddressChange={onAddressChange}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
/>
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
<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,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,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

@ -38,10 +38,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
detail,
(input) => uploadMutation.mutateAsync(input),
);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
@ -109,7 +107,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
}}
onRetry={retryUpload}
onCancel={removeLocal}
onCancel={cancelUpload}
readOnly={isReadOnly}
pending={pending}
/>

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useRef } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -15,17 +15,16 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
export type MediaUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
onProgress: (percent: number) => void;
};
type MediaUploaderProps = {
uploads: MediaFile[];
/** Upload-then-categorize: files only — category assigned after persist. */
/** Upload-then-categorize: files only ÔÇö category assigned after persist. */
onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (localId: string, signal: AbortSignal) => void;
onRetry?: (localId: string) => void;
onCancel?: (localId: string) => void;
/** Legacy local-only mode (table modal fallback). */
onChange?: (next: MediaFile[]) => void;
@ -45,7 +44,6 @@ export function MediaUploader({
pending = false,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
const addFiles = (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return;
@ -65,19 +63,11 @@ export function MediaUploader({
if (accepted.length === 0) return;
if (onUpload) {
const nextControllers: Record<string, AbortController> = { ...controllers };
const requests: MediaUploadRequest[] = accepted.map((file) => {
const localId = `local-${crypto.randomUUID()}`;
const controller = new AbortController();
nextControllers[localId] = controller;
return {
localId,
file,
signal: controller.signal,
onProgress: () => undefined,
};
});
setControllers(nextControllers);
const requests: MediaUploadRequest[] = accepted.map((file) => ({
localId: `local-${crypto.randomUUID()}`,
file,
onProgress: () => undefined,
}));
onUpload(requests);
return;
}
@ -98,18 +88,6 @@ export function MediaUploader({
onChange?.(uploads.filter((u) => u.id !== id));
};
const cancel = (id: string) => {
controllers[id]?.abort();
onCancel?.(id);
};
const retry = (id: string) => {
controllers[id]?.abort();
const controller = new AbortController();
setControllers((prev) => ({ ...prev, [id]: controller }));
onRetry?.(id, controller.signal);
};
return (
<div>
{!readOnly && (
@ -121,8 +99,8 @@ export function MediaUploader({
pending={pending}
onRemove={remove}
onCategorize={onCategorize}
onRetry={onRetry ? retry : undefined}
onCancel={cancel}
onRetry={onRetry}
onCancel={onCancel}
/>
</div>
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -13,13 +13,13 @@ type UploadMutate = (input: {
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
function revokeBlobUrl(url: string | undefined) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
type SessionUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
};
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file);
return {
id: request.localId,
@ -35,15 +35,44 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
};
}
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
function revokeBlobUrl(url: string) {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function useMediaUploadSession(
detail: WorkOrderBoardDetail | undefined,
uploadMutateAsync: UploadMutate,
) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
const workOrderId = detail?.info.id;
const previousWorkOrderIdRef = useRef(workOrderId);
// Drop local tiles and abort in-flight uploads when the bound WO changes.
// Skip the first bind (undefined  id) so an early upload is not cleared.
useEffect(() => {
if (previousWorkOrderIdRef.current === workOrderId) return;
const hadPrevious = previousWorkOrderIdRef.current != null;
previousWorkOrderIdRef.current = workOrderId;
if (!hadPrevious) return;
for (const controller of controllersRef.current.values()) {
controller.abort();
}
controllersRef.current.clear();
setLocalUploads((prev) => {
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
}, [workOrderId]);
const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
@ -58,15 +87,34 @@ export function useMediaUploadSession(
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
}, [serverUploads, localUploads]);
function clearController(localId: string) {
controllersRef.current.delete(localId);
}
function replaceController(localId: string): AbortController {
controllersRef.current.get(localId)?.abort();
const controller = new AbortController();
controllersRef.current.set(localId, controller);
return controller;
}
function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId,
file: request.file,
signal: replaceController(request.localId).signal,
}));
setLocalUploads((prev) => {
const next = [...prev];
for (const request of requests) {
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
revokeBlobUrl(previous?.url);
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
next.push(entry);
@ -75,7 +123,7 @@ export function useMediaUploadSession(
return next;
});
for (const request of requests) {
for (const request of sessionRequests) {
void uploadMutateAsync({
file: request.file,
signal: request.signal,
@ -88,25 +136,16 @@ export function useMediaUploadSession(
},
})
.then(() => {
setLocalUploads((prev) => {
const dropped = prev.filter((item) => isSameAttempt(item, request));
for (const item of dropped) {
revokeBlobUrl(item.url);
}
return prev.filter((item) => !isSameAttempt(item, request));
});
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.catch((error: Error) => {
if (error.name === "AbortError") {
setLocalUploads((prev) => {
const dropped = prev.filter((item) => isSameAttempt(item, request));
for (const item of dropped) {
revokeBlobUrl(item.url);
}
return prev.filter((item) => !isSameAttempt(item, request));
});
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return;
}
// Keep controller entry only while retryable; replaceController on retry.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
@ -123,32 +162,38 @@ export function useMediaUploadSession(
}
}
function retryUpload(localId: string, signal: AbortSignal) {
function retryUpload(localId: string) {
const target = localUploads.find((item) => item.id === localId);
if (!target?.sourceFile) return;
startUploads([
{
localId,
file: target.sourceFile,
signal,
onProgress: () => undefined,
},
]);
}
function removeLocal(id: string) {
function cancelUpload(localId: string) {
controllersRef.current.get(localId)?.abort();
clearController(localId);
setLocalUploads((prev) => {
const dropped = prev.filter((item) => item.id === id);
for (const item of dropped) {
revokeBlobUrl(item.url);
const target = prev.find((item) => item.id === localId);
if (target) {
revokeBlobUrl(target.url);
}
return prev.filter((item) => item.id !== id);
return prev.filter((item) => item.id !== localId);
});
}
function removeLocal(id: string) {
clearController(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
}
function isLocal(id: string) {
return localUploads.some((item) => item.id === id);
}
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
}

View file

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

View file

@ -1,69 +1,24 @@
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
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 { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
function resolveActiveRow(
selected: WorkOrderTableRow | null,
rows: WorkOrderTableRow[],
): WorkOrderTableRow | null {
if (!selected) return null;
return rows.find((row) => row.id === selected.id) ?? selected;
}
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField, patchFieldAsync } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const activeDocRow = useMemo(
() => resolveActiveRow(docRow, tableData.rows),
[docRow, tableData.rows],
);
const activeMediaRow = useMemo(
() => resolveActiveRow(mediaRow, tableData.rows),
[mediaRow, tableData.rows],
);
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
@ -75,7 +30,7 @@ export default function WorkOrdersListPage() {
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onNewWorkOrder={() => setWizardOpen(true)}
onNewWorkOrder={() => page.setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@ -89,8 +44,8 @@ export default function WorkOrdersListPage() {
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
usersLoading={usersLoading}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
@ -100,8 +55,8 @@ export default function WorkOrdersListPage() {
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={tableData.totalCount}
sites={locations}
users={users}
sites={page.locations}
users={page.users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
@ -122,58 +77,82 @@ export default function WorkOrdersListPage() {
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={(row, edit) => {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
onOpenCompDoc={(row) => {
if (!hasCompletionDocService(row.pm)) return;
setDocRow(row);
}}
onOpenMedia={setMediaRow}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={patchField}
onPatch={page.patchField}
/>
<WorkOrdersListOverlays
wizardOpen={wizardOpen}
onWizardOpenChange={setWizardOpen}
users={users}
sites={locations}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
advOpen={filters.advOpen}
onAdvOpenChange={filters.setAdvOpen}
advInitial={filters.advInitial}
onApplyAdvanced={filters.applyAdvancedFilters}
onClearAdvanced={filters.clearAdvancedFilters}
activeDocRow={activeDocRow}
onDocOpenChange={(open) => {
if (!open) setDocRow(null);
}}
completionDocs={completionDocs}
onSaveCompDoc={async (data, status) => {
if (!activeDocRow) return;
// Persist board docStatus first so local generatedAt/notes never outrun the server.
await patchFieldAsync(activeDocRow.id, { docStatus: status });
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
}}
onDateCompletedChange={async (date) => {
if (!activeDocRow) return;
await patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
activeMediaRow={activeMediaRow}
onMediaOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
</Box>
);
}

View file

@ -259,10 +259,7 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
fileType: classifyFileName(item.fileName, item.url),
url: item.url,
category: boardCategoryToMediaCategory(item.category),
purpose:
item.category === "Before" || item.category === "After" || item.category === "Extra"
? item.category
: null,
purpose: item.category,
isLegacy: item.isLegacy || Number(item.id) <= 0,
uploadStatus: "done" as const,
}));

View file

@ -9,8 +9,8 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | null;
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
/** 0–100 while uploading; undefined when idle/complete. */

View file

@ -146,8 +146,21 @@ export function useWorkOrderTableMutations(
},
});
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
patchMutation.mutate({ id, patch });
const patchField = (
id: string | number,
patch: WorkOrderTablePatch,
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
},
) => {
patchMutation.mutate(
{ id, patch },
{
onSuccess: () => options?.onSuccess?.(),
onError: (error) => options?.onError?.(error),
},
);
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>

View file

@ -0,0 +1,24 @@
/**
* Runs file uploads one-by-one while holding an aggregate pending flag true
* until every file has settled (success or failure). Avoids relying on a single
* TanStack Mutation `isPending`, which can flip false when a later small file
* finishes before an earlier large one.
*/
export async function runSequentialUploads<TFile>(
files: readonly TFile[],
upload: (file: TFile) => Promise<unknown>,
setPending: (pending: boolean) => void,
): Promise<void> {
if (files.length === 0) {
return;
}
setPending(true);
try {
for (const file of files) {
await upload(file);
}
} finally {
setPending(false);
}
}

View file

@ -23,8 +23,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"serviceNotes",
"extraServices",
"vendorId",
"company",
"tech",
// company/tech are display companions; board persists vendor via vendorId only
"status",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;

View file

@ -1,7 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
@ -9,6 +8,14 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("react-toastify", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
@ -74,7 +81,6 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
beforeEach(() => {
onSave.mockReset();
onDateCompletedChange.mockReset();
vi.mocked(toast.error).mockReset();
});
function renderDialog(row: WorkOrderTableRow) {
@ -90,12 +96,12 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
row={row}
open
onOpenChange={vi.fn()}
onSave={(data, status) => {
onSave(data, status);
onSave={async (data, status) => {
await onSave(data, status);
patches.push({ docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange(date);
onDateCompletedChange={async (date) => {
await onDateCompletedChange(date);
patches.push({ completedDate: date });
}}
/>
@ -129,7 +135,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(row.status).toBe("Scheduled");
});
it("Generate emits completedDate before doc Yes without auto-setting lifecycle Completed", async () => {
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
const row = baseRow({ completedDate: "2026-07-01" });
const { patches } = renderDialog(row);
@ -150,41 +156,10 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(row.status).toBe("Scheduled");
});
it("Generate keeps Draft and skips docStatus Yes when Date Work Completed is blank", async () => {
const row = baseRow({ completedDate: "" });
renderDialog(row);
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"Date Work Completed is required to generate the document.",
);
});
expect(onDateCompletedChange).not.toHaveBeenCalled();
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
});
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
const row = baseRow({ completedDate: "2026-07-01" });
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={onSave}
onDateCompletedChange={onDateCompletedChange}
/>
</QueryClientProvider>,
);
const row = baseRow({ completedDate: "2026-07-01" });
renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
@ -194,8 +169,61 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
});
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
});
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
const callOrder: string[] = [];
onDateCompletedChange.mockImplementation(async () => {
callOrder.push("date");
throw new Error("completedDate conflict");
});
onSave.mockImplementation(async () => {
callOrder.push("status");
});
const row = baseRow({ completedDate: "2026-07-01" });
renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalled();
expect(onDateCompletedChange).toHaveBeenCalled();
});
expect(callOrder).toEqual(["date"]);
expect(onSave).not.toHaveBeenCalled();
});
it("Save draft persists PM comments notes through onSave", async () => {
const row = baseRow();
renderDialog(row);
const notes = screen.getByPlaceholderText(/add findings/i);
fireEvent.change(notes, { target: { value: "No issues noted" } });
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ notes: "No issues noted" }),
"No",
);
});
});
it("Mark as not required rolls back UI when persist fails", async () => {
onSave.mockRejectedValueOnce(new Error("persist failed"));
const row = baseRow();
renderDialog(row);
fireEvent.click(screen.getByRole("button", { name: /mark as not required/i }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "NN");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: /mark as not required/i })).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /^undo$/i })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,53 @@
import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CompDocDialogFooter } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer";
describe("CompDocDialogFooter exclusive states", () => {
const pdfInputRef = createRef<HTMLInputElement | null>();
const handlers = {
onMarkNotRequired: vi.fn(),
onSaveDraft: vi.fn(),
onGenerate: vi.fn(),
onUndo: vi.fn(),
onDownloadPdf: vi.fn(),
onUploadPdf: vi.fn(),
};
it("does not render draft actions when a signed PDF is on file while docState is still draft", () => {
render(
<CompDocDialogFooter
docState="draft"
isDocumentsReadOnly={false}
rowVersion="1"
isUploading={false}
signOffAttachment="https://example.com/doc.pdf"
generatedAt=""
pdfInputRef={pdfInputRef}
{...handlers}
/>,
);
expect(screen.queryByRole("button", { name: /save draft/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /mark as not required/i })).not.toBeInTheDocument();
expect(screen.getByText(/document on file/i)).toBeInTheDocument();
});
it("renders only draft actions for a clean draft without attachment", () => {
render(
<CompDocDialogFooter
docState="draft"
isDocumentsReadOnly={false}
rowVersion="1"
isUploading={false}
signOffAttachment={null}
generatedAt=""
pdfInputRef={pdfInputRef}
{...handlers}
/>,
);
expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
});
});

View file

@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: {
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
info: { status: "Scheduled" },
media: [
{
id: "11",
@ -80,7 +80,7 @@ function baseRow(): WorkOrderTableRow {
title: "Emergency",
priority: "Sev 1",
primaryDispatchId: null,
rowVersion: "ROW-VER==",
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
};
@ -111,32 +111,4 @@ describe("MediaUploadModal API wiring", () => {
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { fireEvent } = await import("@testing-library/react");
render(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
target: { value: "After" },
});
expect(categorizeMutate).toHaveBeenCalledWith({
mediaId: "11",
category: "After",
workOrderVersion: "DETAIL-VER==",
});
});
});

View file

@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
.mockImplementation(() => new Promise(() => undefined));
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
const firstController = new AbortController();
const localId = "local-1";
const file = makeFile();
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{
localId,
file,
signal: firstController.signal,
onProgress: () => undefined,
},
]);
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads).toHaveLength(1);
});
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
act(() => {
rejectFirst(new Error("network"));
});
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
});
const retryController = new AbortController();
act(() => {
result.current.retryUpload(localId, retryController.signal);
result.current.retryUpload(localId);
});
await waitFor(() => {
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
});
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
expect(retrySignal).toBeInstanceOf(AbortSignal);
expect(retrySignal).not.toBe(firstSignal);
expect(retrySignal.aborted).toBe(false);
});
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
let rejectFirst!: (error: Error) => void;
let resolveRetry!: (value: unknown) => void;
const uploadMutateAsync = vi
.fn()
.mockImplementationOnce(
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
)
.mockImplementation(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
new Promise((resolve, reject) => {
resolveRetry = resolve;
signal?.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
{
localId,
file,
signal: new AbortController().signal,
onProgress: () => undefined,
},
]);
@ -105,25 +108,99 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
});
const retryController = new AbortController();
act(() => {
result.current.retryUpload(localId, retryController.signal);
result.current.retryUpload(localId);
});
await waitFor(() => {
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
});
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
act(() => {
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
retryController.abort();
result.current.removeLocal(localId);
result.current.cancelUpload(localId);
});
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
expect(retryController.signal.aborted).toBe(true);
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
expect(retrySignal.aborted).toBe(true);
// Cancelling must not leave a successful settle path.
expect(() => resolveRetry({})).not.toThrow();
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
});
it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
const uploadMutateAsync = vi.fn().mockImplementation(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
});
}),
);
const detailA = {
info: { id: 1 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const detailB = {
info: { id: 2 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const { result, rerender } = renderHook(
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
{ initialProps: { detail: detailA } },
);
act(() => {
result.current.startUploads([
{
localId: "local-wo-a",
file: makeFile("a.jpg"),
onProgress: () => undefined,
},
]);
});
await waitFor(() => {
expect(result.current.uploads).toHaveLength(1);
});
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
rerender({ detail: detailB });
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
expect(firstSignal.aborted).toBe(true);
});
});

View file

@ -0,0 +1,142 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 100,
woNumber: "100",
site: "SITE",
type: "Emergency",
dispatcherId: "d1",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
location: "Main",
locationId: 5,
pm: "",
serviceNotes: "",
extraServices: [],
vendorId: "v1",
company: "Acme",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
function detailWithMedia(
media: WorkOrderBoardDetail["media"],
infoOverrides: Partial<WorkOrderTableRow> = {},
): WorkOrderBoardDetail {
return {
info: baseInfo(infoOverrides),
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
comments: [],
audit: [],
media,
};
}
function idleMutation() {
return {
mutate: vi.fn(),
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
} as never;
}
describe("useSlideOverMediaState completion filter", () => {
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
const detail = detailWithMedia([
{
id: "1",
category: "Before",
url: "https://cdn.example.com/before.jpg",
fileName: "before.jpg",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "2",
category: "After",
url: "https://cdn.example.com/after.jpg",
fileName: "after.jpg",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "3",
category: "Completion",
url: "https://cdn.example.com/completion.pdf",
fileName: "completion.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "4",
category: "Extra",
url: "https://cdn.example.com/extra.pdf",
fileName: "extra.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
]);
const { result } = renderHook(() =>
useSlideOverMediaState({
detailForRow: detail,
uploadMediaMutation: idleMutation(),
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
expect(result.current.completionMedia.map((item) => item.purpose)).toEqual([
"Before",
"After",
"Completion",
]);
expect(
result.current.completionMedia.every((item) =>
["Photo", "Video", "Report", "Other"].includes(item.category),
),
).toBe(true);
expect(result.current.extrasUploads).toHaveLength(1);
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
});
});

View file

@ -17,6 +17,7 @@ const unscheduledRow: WorkOrderTableRow = {
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
@ -38,7 +39,6 @@ const unscheduledRow: WorkOrderTableRow = {
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-15",
completedDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",

View file

@ -0,0 +1,171 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/providers/auth-context", () => ({
useAuthContext: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab", () => ({
SlideOverInfoTab: ({
draft,
onDraftChange,
}: {
draft: WorkOrderTableRow;
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
}) => (
<label>
WO number
<input
aria-label="WO number"
value={draft.woNumber}
onChange={(event) => onDraftChange({ woNumber: event.target.value })}
/>
</label>
),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("WorkOrderSlideOver failed-save retry (no detail yet)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps draft dirty and Save enabled after optimistic parent row + failed save", async () => {
const initial = baseRow();
let currentRow = initial;
const onSave = vi.fn(
(
_id: string | number,
patch: Partial<WorkOrderTableRow>,
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
) => {
currentRow = { ...currentRow, ...patch };
rerenderWithRow(currentRow);
currentRow = initial;
rerenderWithRow(currentRow);
options?.onError?.(new Error("save failed"));
},
);
let rerenderWithRow = (_row: WorkOrderTableRow) => {
/* assigned after first render */
};
const view = render(
<WorkOrderSlideOver
row={currentRow}
editMode
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[currentRow]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
rerenderWithRow = (row) => {
view.rerender(
<WorkOrderSlideOver
row={row}
editMode
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[row]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
};
fireEvent.change(screen.getByLabelText("WO number"), { target: { value: "WO-99" } });
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).not.toBeDisabled();
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).not.toBeDisabled();
});
});
});

View file

@ -81,6 +81,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderVendors: () => ({
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
}),
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({

View file

@ -135,6 +135,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderLocations: () => ({ data: [] }),
useWorkOrderVendors: () => ({ data: [] }),
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
@ -198,7 +205,7 @@ describe("WorkOrdersListHeader slice affordances", () => {
});
describe("WorkOrdersListPage board-core public interface", () => {
it("opens New WO wizard on click, enforces step validation, closes, and View navigates to detail", async () => {
it("opens New WO wizard on click, enforces step validation, closes, and View opens the slide-over", async () => {
navigate.mockReset();
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
@ -217,7 +224,10 @@ describe("WorkOrdersListPage board-core public interface", () => {
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
expect(navigate).toHaveBeenCalledWith("/workorders/77");
expect(await screen.findByLabelText("Work order details")).toBeInTheDocument();
expect(screen.getAllByText(/WO-77/i).length).toBeGreaterThan(0);
expect(navigate).not.toHaveBeenCalled();
});
});

View file

@ -172,13 +172,40 @@ describe("mapWorkOrderBoardDetail", () => {
name: "before.jpg",
url: "https://cdn.example.com/before.jpg",
category: "Photo",
purpose: "Before",
fileType: "image",
isLegacy: false,
});
expect(files[1]?.category).toBe("Other");
expect(files[1]?.purpose).toBe("Extra");
expect(files[1]?.fileType).toBe("pdf");
});
it("maps After and Completion board categories to purpose for completion UI", () => {
const after = mapBoardDetailMediaItem({
id: 20,
category: 2,
url: "https://cdn.example.com/after.jpg",
fileName: "after.jpg",
});
const completion = mapBoardDetailMediaItem({
id: 21,
category: 4,
url: "https://cdn.example.com/completion.pdf",
fileName: "completion.pdf",
});
const files = boardMediaItemsToMediaFiles([after, completion]);
expect(files[0]).toMatchObject({
category: "Photo",
purpose: "After",
});
expect(files[1]).toMatchObject({
category: "Report",
purpose: "Completion",
fileType: "pdf",
});
});
it("maps numeric category and marks negative ids as legacy", () => {
const legacy = mapBoardDetailMediaItem({
id: -1,

View file

@ -2,8 +2,10 @@ import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type {
CompletionDocStatus,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
const getById = vi.fn();
const update = vi.fn();
@ -34,34 +36,18 @@ function makeWrapper() {
};
}
const BASE_DETAIL: WorkOrderDetail = {
id: 42,
internalWONumber: "WO-42",
workOrderNumber: "WO-42",
title: "PM — SEA1",
location: "Seattle",
locationId: "loc-1",
locationZip: "98101",
assignedTo: "Dispatcher",
dueDate: "",
createdDate: "2026-06-01",
createdDateFull: "2026-06-01T00:00:00Z",
description: "",
priority: "Sev 3",
type ServerState = {
status: string;
scheduledDate: string;
completedDate: string;
docStatus: CompletionDocStatus;
};
const BASE_SERVER: ServerState = {
status: "Open",
po: "",
tt: "",
problem: "HVAC",
trade: "",
subTrade: "",
vendorNTE: "",
scheduledDate: "2026-06-01",
completedDate: "",
docStatus: "No",
source: "PM",
comments: [],
auditLog: [],
dispatches: [],
};
const BASE_ROW = {
@ -110,7 +96,7 @@ const BASE_ROW = {
} as WorkOrderTableRow;
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: WorkOrderDetail;
let serverState: ServerState;
let events: string[];
let rowState: WorkOrderTableRow;
@ -118,7 +104,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
getById.mockReset();
update.mockReset();
patchBoardField.mockReset();
serverState = { ...BASE_DETAIL };
serverState = { ...BASE_SERVER };
rowState = { ...BASE_ROW };
events = [];
@ -131,7 +117,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
await delay(5);
if (payload.field === "docStatus" && typeof payload.value === "string") {
const docStatus =
payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]);
payload.value === "NotNeeded" ? "NN" : (payload.value as CompletionDocStatus);
serverState = { ...serverState, docStatus };
rowState = {
...rowState,
@ -217,7 +203,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
expect(serverState.docStatus).toBe("Yes");
expect(serverState.completedDate).toBe("2026-07-20");
expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
expect(serverState.status).toBe(BASE_DETAIL.status);
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
expect(serverState.status).toBe(BASE_SERVER.status);
});
});

View file

@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
describe("runSequentialUploads", () => {
it("keeps pending true across the full batch; second upload starts only after the first settles", async () => {
const pendingLog: boolean[] = [];
const setPending = (pending: boolean) => {
pendingLog.push(pending);
};
let resolveFirst: (() => void) | undefined;
let resolveSecond: (() => void) | undefined;
const first = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const second = new Promise<void>((resolve) => {
resolveSecond = resolve;
});
const upload = vi
.fn()
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
const done = runSequentialUploads(["large.bin", "small.bin"], upload, setPending);
expect(pendingLog).toEqual([true]);
expect(upload).toHaveBeenCalledTimes(1);
expect(upload).toHaveBeenCalledWith("large.bin");
// First file still in flight — pending must stay true.
await Promise.resolve();
expect(pendingLog).toEqual([true]);
expect(upload).toHaveBeenCalledTimes(1);
resolveFirst?.();
await Promise.resolve();
await Promise.resolve();
expect(upload).toHaveBeenCalledTimes(2);
expect(upload).toHaveBeenNthCalledWith(2, "small.bin");
expect(pendingLog).toEqual([true]);
resolveSecond?.();
await done;
expect(pendingLog).toEqual([true, false]);
});
it("clears pending when a middle upload rejects", async () => {
const setPending = vi.fn();
const upload = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("upload failed"));
await expect(runSequentialUploads(["a", "b", "c"], upload, setPending)).rejects.toThrow(
"upload failed",
);
expect(setPending.mock.calls.map((call) => call[0])).toEqual([true, false]);
expect(upload).toHaveBeenCalledTimes(2);
});
});

View file

@ -58,9 +58,23 @@ describe("slide-over-draft", () => {
it("exposes only board-persistable edit keys", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
});
it("tracks vendor via vendorId only (company/tech are display companions)", () => {
const row = baseRow();
const draft = baseRow({ company: "Other Co", tech: "Pat" });
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
expect(buildSlideOverPatch(draft, row)).toEqual({});
const vendorDraft = baseRow({ vendorId: "v2", company: "Other Co", tech: "Pat" });
expect(isSlideOverDraftDirty(vendorDraft, row)).toBe(true);
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/**
* Mirrors the failed-save path: optimistic parent row absorbs the patch, then
* rolls back, while draft is compared against a frozen pre-save baseline.
*/
function afterFailedOptimisticSave(input: {
baseline: WorkOrderTableRow;
draft: WorkOrderTableRow;
optimisticParent: WorkOrderTableRow;
rolledBackParent: WorkOrderTableRow;
}) {
const dirtyAgainstOptimistic = isSlideOverDraftDirty(input.draft, input.optimisticParent);
const dirtyAgainstBaseline = isSlideOverDraftDirty(input.draft, input.baseline);
const dirtyAgainstRolledBack = isSlideOverDraftDirty(input.draft, input.rolledBackParent);
const retryPatch = buildSlideOverPatch(input.draft, input.baseline);
return {
dirtyAgainstOptimistic,
dirtyAgainstBaseline,
dirtyAgainstRolledBack,
retryPatch,
canRetry: dirtyAgainstBaseline && Object.keys(retryPatch).length > 0,
};
}
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 1,
woNumber: "1001",
site: "SITE-A",
type: "PM",
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
location: "Main",
locationId: 5,
pm: "Plumbing",
serviceNotes: "",
extraServices: [],
vendorId: "v1",
company: "Acme",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "09:00",
status: "Incomplete",
docStatus: "",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "rv1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("slide-over failed-save baseline contract", () => {
it("stays dirty vs frozen baseline after optimistic parent absorbs the edit", () => {
const baseline = row({ woNumber: "1001" });
const draft = row({ woNumber: "2002" });
const optimisticParent = row({ woNumber: "2002" });
const result = afterFailedOptimisticSave({
baseline,
draft,
optimisticParent,
rolledBackParent: baseline,
});
// Bug the review caught: comparing against optimistic parent clears dirty.
expect(result.dirtyAgainstOptimistic).toBe(false);
expect(result.dirtyAgainstBaseline).toBe(true);
expect(result.dirtyAgainstRolledBack).toBe(true);
expect(result.canRetry).toBe(true);
expect(result.retryPatch).toEqual({ woNumber: "2002" });
});
});

View file

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
/**
* Mirrors WorkOrderSlideOver commit: exit edit mode only after a successful save
* (or when there is nothing to persist).
*/
function commitSlideOverEdit(input: {
patchKeys: number;
onSave: (options: { onSuccess?: () => void }) => void;
exitEdit: () => void;
clearUnsaved: () => void;
}) {
if (input.patchKeys === 0) {
input.exitEdit();
input.clearUnsaved();
return;
}
input.onSave({
onSuccess: () => {
input.exitEdit();
input.clearUnsaved();
},
});
}
describe("slide-over commit save contract", () => {
it("exits edit mode immediately when there is no patch", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
const onSave = vi.fn();
commitSlideOverEdit({ patchKeys: 0, onSave, exitEdit, clearUnsaved });
expect(onSave).not.toHaveBeenCalled();
expect(exitEdit).toHaveBeenCalledOnce();
expect(clearUnsaved).toHaveBeenCalledOnce();
});
it("keeps edit mode when save fails (onSuccess never runs)", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
commitSlideOverEdit({
patchKeys: 1,
onSave: () => {
/* rejected — do not call onSuccess */
},
exitEdit,
clearUnsaved,
});
expect(exitEdit).not.toHaveBeenCalled();
expect(clearUnsaved).not.toHaveBeenCalled();
});
it("exits edit mode only from onSuccess after a rejected-then-retry path would not", () => {
const exitEdit = vi.fn();
const clearUnsaved = vi.fn();
let success: (() => void) | undefined;
commitSlideOverEdit({
patchKeys: 2,
onSave: (options) => {
success = options.onSuccess;
},
exitEdit,
clearUnsaved,
});
expect(exitEdit).not.toHaveBeenCalled();
success?.();
expect(exitEdit).toHaveBeenCalledOnce();
expect(clearUnsaved).toHaveBeenCalledOnce();
});
});

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fmtDateRange,
fmtShortDate,
pad2,
rangeDayCount,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
describe("fmtShortDate", () => {
@ -29,3 +31,27 @@ describe("rangeDayCount", () => {
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
});
});
describe("todayIso", () => {
afterEach(() => {
vi.useRealTimers();
});
it("uses local calendar day, not UTC ISO date, near midnight", () => {
// 02:30 UTC is still the previous local evening in Americas (UTC−N).
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-11T02:30:00.000Z"));
const now = new Date();
const expectedLocal = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
const utcSlice = now.toISOString().slice(0, 10);
expect(todayIso()).toBe(expectedLocal);
expect(utcSlice).toBe("2026-08-11");
if (now.getTimezoneOffset() > 0) {
expect(todayIso()).toBe("2026-08-10");
expect(todayIso()).not.toBe(utcSlice);
}
});
});