mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
chore(work-orders): merge origin/dev into completedDate column branch
This commit is contained in:
commit
eadb0bda8b
69 changed files with 3840 additions and 256 deletions
|
|
@ -0,0 +1,19 @@
|
|||
import type { ElementType } from "react";
|
||||
|
||||
type CompDocCoLineProps = {
|
||||
icon: ElementType;
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 break-words"
|
||||
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
|
||||
>
|
||||
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
|
||||
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ElementType } from "react";
|
||||
|
||||
type CompDocDetailRowProps = {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
last?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2.5"
|
||||
style={{
|
||||
padding: "10px 0",
|
||||
borderBottom: last ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-primary)",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||
import { CompDocEditableRow } from "./comp-doc-editable-row";
|
||||
|
||||
type CompDocDetailsSectionProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
editAddress: string;
|
||||
onAddressChange: (value: string) => void;
|
||||
editDateCompleted: string;
|
||||
onDateCompletedChange: (value: string) => void;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocDetailsSection({
|
||||
row,
|
||||
site,
|
||||
editAddress,
|
||||
onAddressChange,
|
||||
editDateCompleted,
|
||||
onDateCompletedChange,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
isGenerated,
|
||||
}: CompDocDetailsSectionProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Work Order Details
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||
<CompDocDetailRow
|
||||
icon={MapPin}
|
||||
label="Site Code"
|
||||
value={`${row.site} · Amazon.com Services LLC`}
|
||||
/>
|
||||
<CompDocEditableRow
|
||||
label="Address"
|
||||
value={editAddress}
|
||||
onChange={onAddressChange}
|
||||
placeholder={site.fullAddress !== "—" ? site.fullAddress : "Street address"}
|
||||
disabled={isGenerated}
|
||||
/>
|
||||
<CompDocEditableRow
|
||||
label="Date Work Completed"
|
||||
value={editDateCompleted}
|
||||
onChange={onDateCompletedChange}
|
||||
type="date"
|
||||
disabled={isGenerated}
|
||||
/>
|
||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||
<CompDocDetailRow
|
||||
icon={Wrench}
|
||||
label="Seahaven Representative"
|
||||
value={seaRep || "—"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Ban, Save, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterDraftProps = {
|
||||
onMarkNotRequired: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onGenerate: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterDraft({
|
||||
onMarkNotRequired,
|
||||
onSaveDraft,
|
||||
onGenerate,
|
||||
}: CompDocDialogFooterDraftProps) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
onClick={onMarkNotRequired}
|
||||
>
|
||||
<Ban size={14} /> Mark as not required
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
onClick={onSaveDraft}
|
||||
>
|
||||
<Save size={14} /> Save draft
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
<Sparkles size={14} /> Generate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { CheckCircle2, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterGeneratedProps = {
|
||||
generatedAt: string;
|
||||
onDownloadPdf: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterGenerated({
|
||||
generatedAt,
|
||||
onDownloadPdf,
|
||||
}: CompDocDialogFooterGeneratedProps) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||
>
|
||||
<CheckCircle2 size={14} /> Generated {generatedAt}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={onDownloadPdf}
|
||||
>
|
||||
<Download size={14} /> Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Ban, Sparkles, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx";
|
||||
|
||||
type CompDocDialogFooterNotRequiredProps = {
|
||||
onUndo: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
||||
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<Undo2 size={14} /> Undo
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
disabled
|
||||
title="Marked not required — undo to generate"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Sparkles size={14} /> Generate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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";
|
||||
|
||||
type CompDocDialogFooterProps = {
|
||||
docState: DocState;
|
||||
generatedAt: string;
|
||||
onMarkNotRequired: () => void;
|
||||
onUndo: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onGenerate: () => void;
|
||||
onDownloadPdf: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogFooter({
|
||||
docState,
|
||||
generatedAt,
|
||||
onMarkNotRequired,
|
||||
onUndo,
|
||||
onSaveDraft,
|
||||
onGenerate,
|
||||
onDownloadPdf,
|
||||
}: CompDocDialogFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-3"
|
||||
style={{
|
||||
padding: "14px 20px",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{docState === "draft" && (
|
||||
<CompDocDialogFooterDraft
|
||||
onMarkNotRequired={onMarkNotRequired}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onGenerate={onGenerate}
|
||||
/>
|
||||
)}
|
||||
{docState === "generated" && (
|
||||
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
|
||||
)}
|
||||
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { FileText, X } from "lucide-react";
|
||||
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
type CompDocDialogHeaderProps = {
|
||||
row: WorkOrderTableRow;
|
||||
cfg: CompletionDocStateConfig;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
style={{
|
||||
height: 60,
|
||||
padding: "0 20px",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Completion Document
|
||||
</div>
|
||||
<div
|
||||
className="truncate"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
WO{" "}
|
||||
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
{" · "}
|
||||
{serviceTitle(row)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
height: 26,
|
||||
padding: "0 10px",
|
||||
borderRadius: 9999,
|
||||
background: cfg.pillBg,
|
||||
color: cfg.pillText,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 9999,
|
||||
background: cfg.pillText,
|
||||
}}
|
||||
/>
|
||||
{cfg.pillLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { 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 { CompDocPreview } from "./comp-doc-preview";
|
||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||
|
||||
type CompDocDialogProps = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data?: CompDocData;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function CompDocDialog({
|
||||
row,
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
}: CompDocDialogProps) {
|
||||
const {
|
||||
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 });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
width: "min(1024px, calc(100vw - 32px))",
|
||||
maxWidth: "none",
|
||||
},
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
dividers={false}
|
||||
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
|
||||
sx={{
|
||||
p: 0,
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||
<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)} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<CompDocPreview
|
||||
row={row}
|
||||
site={site}
|
||||
notes={notes}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
isNotRequired={isNotRequired}
|
||||
/>
|
||||
<CompDocSidebar
|
||||
row={row}
|
||||
site={site}
|
||||
docState={docState}
|
||||
generatedAt={generatedAt}
|
||||
notes={notes}
|
||||
editAddress={editAddress}
|
||||
onAddressChange={setEditAddress}
|
||||
editDateCompleted={editDateCompleted}
|
||||
amazonRep={amazonRep}
|
||||
seaRep={seaRep}
|
||||
isGenerated={isGenerated}
|
||||
onNotesChange={setNotes}
|
||||
onDateCompletedChange={setEditDateCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CompDocDialogFooter
|
||||
docState={docState}
|
||||
generatedAt={generatedAt}
|
||||
onMarkNotRequired={handleMarkNotRequired}
|
||||
onUndo={handleUndo}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onGenerate={handleGenerate}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function showCompDocDraftSavedToast() {
|
||||
toast(
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
color: "#fff",
|
||||
padding: "10px 18px",
|
||||
borderRadius: 9999,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Draft saved
|
||||
</div>,
|
||||
{
|
||||
position: "bottom-center",
|
||||
autoClose: 3000,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
icon: false,
|
||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
|
||||
|
||||
const COMP_DOC_INPUT_STYLE: CSSProperties = {
|
||||
fontSize: 13,
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
};
|
||||
|
||||
const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
type CompDocEditableRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocEditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
}: CompDocEditableRowProps) {
|
||||
return (
|
||||
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={disabled}
|
||||
placeholder={placeholder}
|
||||
className={COMP_DOC_INPUT_CLS}
|
||||
style={{
|
||||
...COMP_DOC_INPUT_STYLE,
|
||||
...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Ban } from "lucide-react";
|
||||
|
||||
export function CompDocNotRequiredStamp() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
style={{
|
||||
marginTop: "30%",
|
||||
transform: "rotate(-15deg)",
|
||||
border: "3px solid var(--warning)",
|
||||
color: "var(--warning)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 18px",
|
||||
fontWeight: 800,
|
||||
fontSize: 22,
|
||||
letterSpacing: "0.06em",
|
||||
background: "rgba(255,255,255,0.65)",
|
||||
}}
|
||||
>
|
||||
<Ban size={20} /> NOT REQUIRED
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
type CompDocNotesFieldProps = {
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
PM Comments{" "}
|
||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||
— optional, included in the document
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
readOnly={isGenerated}
|
||||
rows={3}
|
||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||
className="mt-2 w-full resize-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
...(isGenerated
|
||||
? {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { CheckCircle2, FileText } from "lucide-react";
|
||||
|
||||
type CompDocPreviewBadgeProps = {
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: "0 12px",
|
||||
borderRadius: 9999,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{isGenerated ? (
|
||||
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)}
|
||||
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
|
||||
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||
|
||||
export function CompDocPreviewHeader() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap items-start justify-between gap-y-3"
|
||||
style={{ padding: "20px 0 12px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src="/seahaven-logo.svg"
|
||||
alt="Seahaven Industries"
|
||||
style={{ height: 34, width: "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
||||
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
||||
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
||||
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
||||
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||
|
||||
type CompDocPreviewProcedureProps = {
|
||||
row: WorkOrderTableRow;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
|
||||
const proc = procedureFor(row.pm);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||
{SAFETY_BULLETS.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
margin: "5px 0",
|
||||
color: "#161E38",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
{proc.heading}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#58595B",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
{proc.intro}
|
||||
</Text>
|
||||
<CompDocProcedureList proc={proc} />
|
||||
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
PM Comments
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#161E38",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
type CompDocPreviewSummaryProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
generatedAt: string;
|
||||
isGenerated: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreviewSummary({
|
||||
row,
|
||||
site,
|
||||
generatedAt,
|
||||
isGenerated,
|
||||
}: CompDocPreviewSummaryProps) {
|
||||
return (
|
||||
<>
|
||||
{isGenerated && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 9999,
|
||||
background: "var(--doc-yes-bg)",
|
||||
color: "var(--color-success)",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="h2"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.02em",
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
{serviceTitle(row).toUpperCase()}
|
||||
</Text>
|
||||
<Text
|
||||
family="mono"
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: "#1C75BC",
|
||||
margin: "2px 0",
|
||||
}}
|
||||
>
|
||||
WO# {row.woNumber}
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#161E38",
|
||||
margin: "2px 0",
|
||||
}}
|
||||
>
|
||||
Site ID — {row.site}
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||
Amazon.com Services LLC {row.site}
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||
{site.street},
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||
{site.label}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||
{row.tech || "—"}
|
||||
{row.company ? ` · ${row.company}` : ""}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
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";
|
||||
|
||||
type CompDocPreviewProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
notes: string;
|
||||
generatedAt: string;
|
||||
isGenerated: boolean;
|
||||
isNotRequired: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreview({
|
||||
row,
|
||||
site,
|
||||
notes,
|
||||
generatedAt,
|
||||
isGenerated,
|
||||
isNotRequired,
|
||||
}: CompDocPreviewProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{
|
||||
width: "58%",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
style={{ padding: "0 20px 20px" }}
|
||||
>
|
||||
<div
|
||||
className="relative break-words"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
|
||||
type CompDocProcedureListProps = {
|
||||
proc: Procedure;
|
||||
};
|
||||
|
||||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||
return (
|
||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||
{proc.steps.map((st, i) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
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";
|
||||
|
||||
type CompDocSidebarProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
docState: DocState;
|
||||
generatedAt: string;
|
||||
notes: string;
|
||||
editAddress: string;
|
||||
onAddressChange: (value: string) => void;
|
||||
editDateCompleted: string;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
isGenerated: boolean;
|
||||
onNotesChange: (value: string) => void;
|
||||
onDateCompletedChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CompDocSidebar({
|
||||
row,
|
||||
site,
|
||||
docState,
|
||||
generatedAt,
|
||||
notes,
|
||||
editAddress,
|
||||
onAddressChange,
|
||||
editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
isGenerated,
|
||||
onNotesChange,
|
||||
onDateCompletedChange,
|
||||
}: CompDocSidebarProps) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStatusCardDescription,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
|
||||
type CompDocStatusCardProps = {
|
||||
docState: DocState;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export function CompDocStatusCard({ docState, generatedAt }: CompDocStatusCardProps) {
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
const StateIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
background: cfg.cardBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: cfg.cardIconBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{cfg.cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{docStatusCardDescription(docState, generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
|
|
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
|
|||
type CompDocCellProps = {
|
||||
status: CompletionDocStatus;
|
||||
woType: WOType | "";
|
||||
/** Primary service (`pm`). Required before CompDoc can open. */
|
||||
pm?: string;
|
||||
mediaCount?: number;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpen?: () => void;
|
||||
|
|
@ -18,6 +21,7 @@ type CompDocCellProps = {
|
|||
export function CompDocCell({
|
||||
status,
|
||||
woType,
|
||||
pm = "",
|
||||
mediaCount = 0,
|
||||
onOpen,
|
||||
onOpenMedia,
|
||||
|
|
@ -43,6 +47,20 @@ export function CompDocCell({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!hasCompletionDocService(pm)) {
|
||||
return (
|
||||
<span
|
||||
title="Select a service before opening the completion document"
|
||||
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.65 }}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<FileText size={15} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
Select service
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const generated = status === "Yes";
|
||||
const notRequired = status === "NN";
|
||||
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
|
|
@ -13,7 +14,7 @@ type WoTableRowServiceCellsProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
search?: string;
|
||||
mutationCellStyle?: React.CSSProperties;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
|
@ -96,6 +97,7 @@ export function WoTableRowServiceCells({
|
|||
<CompDocCell
|
||||
status={row.docStatus}
|
||||
woType={row.type}
|
||||
pm={row.pm}
|
||||
mediaCount={row.mediaCount}
|
||||
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListOverlaysProps = {
|
||||
wizardOpen: boolean;
|
||||
onWizardOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
pmTypes: string[];
|
||||
vendorTechs: string[];
|
||||
advOpen: boolean;
|
||||
onAdvOpenChange: (open: boolean) => void;
|
||||
advInitial: WorkOrderAdvancedFilters;
|
||||
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
|
||||
onClearAdvanced: () => void;
|
||||
activeDocRow: WorkOrderTableRow | null;
|
||||
onDocOpenChange: (open: boolean) => void;
|
||||
completionDocs: Record<string, CompDocData>;
|
||||
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange: (date: string) => void | Promise<void>;
|
||||
activeMediaRow: WorkOrderTableRow | null;
|
||||
onMediaOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListOverlays({
|
||||
wizardOpen,
|
||||
onWizardOpenChange,
|
||||
users,
|
||||
sites,
|
||||
pmTypes,
|
||||
vendorTechs,
|
||||
advOpen,
|
||||
onAdvOpenChange,
|
||||
advInitial,
|
||||
onApplyAdvanced,
|
||||
onClearAdvanced,
|
||||
activeDocRow,
|
||||
onDocOpenChange,
|
||||
completionDocs,
|
||||
onSaveCompDoc,
|
||||
onDateCompletedChange,
|
||||
activeMediaRow,
|
||||
onMediaOpenChange,
|
||||
}: WorkOrdersListOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={advOpen}
|
||||
onOpenChange={onAdvOpenChange}
|
||||
initial={advInitial}
|
||||
onApply={onApplyAdvanced}
|
||||
onClearAll={onClearAdvanced}
|
||||
sites={sites}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={onDocOpenChange}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
|
||||
// Notes/generatedAt stay client-local (no content endpoint on this branch).
|
||||
onSave={onSaveCompDoc}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={onMediaOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,65 @@
|
|||
import { type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { MediaUploader } from "./media-uploader";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
type MediaUploadModalProps = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
};
|
||||
|
||||
export function MediaUploadModal({
|
||||
row,
|
||||
open,
|
||||
onOpenChange,
|
||||
uploads,
|
||||
onChange,
|
||||
}: MediaUploadModalProps) {
|
||||
function resolveWorkOrderVersion(
|
||||
detailRowVersion: string | null | undefined,
|
||||
rowVersion: string | null | undefined,
|
||||
): string | null {
|
||||
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
|
||||
return version || null;
|
||||
}
|
||||
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
||||
detail,
|
||||
(input) => uploadMutation.mutateAsync(input),
|
||||
);
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
if (!version) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return null;
|
||||
}
|
||||
return version;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={handleClose}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
|
|
@ -45,11 +78,42 @@ export function MediaUploadModal({
|
|||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order.
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
<MediaUploader uploads={uploads} onChange={onChange} />
|
||||
{isLoading && !detail ? (
|
||||
<LoadingRegion label="Loading media">
|
||||
<Stack spacing={1.5}>
|
||||
<Skeleton variant="rounded" height={100} animation="wave" />
|
||||
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||
</Stack>
|
||||
</LoadingRegion>
|
||||
) : (
|
||||
<MediaUploader
|
||||
uploads={uploads}
|
||||
onUpload={startUploads}
|
||||
onRemove={(id) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onRetry={retryUpload}
|
||||
onCancel={removeLocal}
|
||||
readOnly={isReadOnly}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
|
|
@ -57,6 +121,7 @@ export function MediaUploadModal({
|
|||
variant="outline"
|
||||
size="default"
|
||||
className="text-[12px]"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import type { RefObject } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
onChange={(e) => {
|
||||
onFilesSelected(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
|
||||
|
||||
type MediaUploaderGridItemChromeProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemChrome({
|
||||
file,
|
||||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridItemChromeProps) {
|
||||
const uploading = file.uploadStatus === "uploading";
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<MediaUploaderGridItemPreview file={file} />
|
||||
{Boolean(uploading) && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${file.progress ?? 0}%`,
|
||||
background: "var(--color-brand-primary, #2563eb)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(uploading && onCancel) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel?.(file.id)}
|
||||
title="Cancel upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(failed && onRetry) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry?.(file.id)}
|
||||
title="Retry upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(showDelete && !uploading) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
|
||||
|
||||
type MediaUploaderGridItemMetaProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemMeta({
|
||||
file,
|
||||
readOnly,
|
||||
pending,
|
||||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
{Boolean(failed) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
|
||||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
if (value) onCategorize?.(file.id, value);
|
||||
}}
|
||||
className="w-full rounded border px-1 py-0.5 text-[10px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<option value="">Assign purpose…</option>
|
||||
{PURPOSE_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
|
||||
type MediaUploaderGridItemPreviewProps = {
|
||||
file: MediaFile;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
|
||||
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||
|
||||
if (file.fileType === "image") {
|
||||
return (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.fileType === "video") {
|
||||
return (
|
||||
<>
|
||||
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
|
||||
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
|
||||
|
||||
type MediaUploaderGridItemProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItem({
|
||||
file,
|
||||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridItemProps) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<MediaUploaderGridItemChrome
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<MediaUploaderGridItemMeta
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
|
||||
|
||||
type MediaUploaderGridProps = {
|
||||
uploads: MediaFile[];
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGrid({
|
||||
uploads,
|
||||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridProps) {
|
||||
if (uploads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{uploads.map((file) => (
|
||||
<MediaUploaderGridItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,143 +1,129 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } 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";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
export type MediaUploadRequest = {
|
||||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
onProgress: (percent: number) => void;
|
||||
};
|
||||
|
||||
type MediaUploaderProps = {
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
/** 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;
|
||||
onCancel?: (localId: string) => void;
|
||||
/** Legacy local-only mode (table modal fallback). */
|
||||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
|
||||
export function MediaUploader({
|
||||
uploads,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
|
||||
if (readOnly || 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) 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);
|
||||
onUpload(requests);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange([...uploads, ...accepted.map(makeMediaFile)]);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
|
||||
const remove = (id: string) => {
|
||||
if (readOnly || pending) return;
|
||||
const target = uploads.find((u) => u.id === id);
|
||||
if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
|
||||
if (onRemove) {
|
||||
onRemove(id);
|
||||
return;
|
||||
}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
Drag files here or click to browse
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
{uploads.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{uploads.map((u) => {
|
||||
const TypeIcon = FILETYPE_ICON[u.fileType];
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
{u.fileType === "image" ? (
|
||||
<img
|
||||
src={u.url}
|
||||
alt={u.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : u.fileType === "video" ? (
|
||||
<>
|
||||
<video
|
||||
src={u.url}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(u.id)}
|
||||
title="Remove"
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<div
|
||||
className="truncate"
|
||||
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{u.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={remove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry ? retry : undefined}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
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 { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
type CompDocSite = { street: string; fullAddress: string };
|
||||
|
||||
export function buildCompDocAddressLine(site: CompDocSite): string {
|
||||
if (site.street.trim()) return site.street.trim();
|
||||
return site.fullAddress !== "—" ? site.fullAddress : "";
|
||||
}
|
||||
|
||||
function locationUpdatePayload(location: Location, address: string): Location {
|
||||
return {
|
||||
...location,
|
||||
address,
|
||||
};
|
||||
}
|
||||
|
||||
type CreateCompDocDialogActionsParams = {
|
||||
row: WorkOrderTableRow;
|
||||
notes: string;
|
||||
generatedAt: string;
|
||||
editDateCompleted: string;
|
||||
editAddress: string;
|
||||
docState: DocState;
|
||||
docFields: DocFields;
|
||||
site: CompDocSite;
|
||||
locId: string;
|
||||
isMockLocation: boolean;
|
||||
locationDetail: Location | undefined;
|
||||
queryClient: QueryClient;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
setDocState: (state: DocState) => void;
|
||||
setGeneratedAt: (value: string) => void;
|
||||
};
|
||||
|
||||
export function createCompDocDialogActions({
|
||||
row,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
editAddress,
|
||||
docState,
|
||||
docFields,
|
||||
site,
|
||||
locId,
|
||||
isMockLocation,
|
||||
locationDetail,
|
||||
queryClient,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
setDocState,
|
||||
setGeneratedAt,
|
||||
}: CreateCompDocDialogActionsParams) {
|
||||
async function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus): Promise<void> {
|
||||
await onSave({ notes, generatedAt, ...extra }, status);
|
||||
}
|
||||
|
||||
async function syncCompletedDateIfChanged(): Promise<void> {
|
||||
if (editDateCompleted !== (row.completedDate || "")) {
|
||||
await onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAddressIfChanged(): Promise<void> {
|
||||
const next = editAddress.trim();
|
||||
const current = buildCompDocAddressLine(site);
|
||||
if (next === current) return;
|
||||
if (isMockLocation || !locId) return;
|
||||
if (!locationDetail) {
|
||||
throw new Error("Location details are still loading. Try saving again.");
|
||||
}
|
||||
|
||||
await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
function refreshAuthoritativeState() {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
if (locId && !isMockLocation) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkNotRequired() {
|
||||
const previous = docState;
|
||||
setDocState("not-required");
|
||||
try {
|
||||
await persist({}, "NN");
|
||||
} catch (error) {
|
||||
setDocState(previous);
|
||||
refreshAuthoritativeState();
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to mark document as not required",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
const previous = docState;
|
||||
setDocState("draft");
|
||||
try {
|
||||
await persist({}, "No");
|
||||
} catch (error) {
|
||||
setDocState(previous);
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
try {
|
||||
await syncCompletedDateIfChanged();
|
||||
await syncAddressIfChanged();
|
||||
await persist({}, "No");
|
||||
showCompDocDraftSavedToast();
|
||||
} catch (error) {
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!editDateCompleted.trim()) {
|
||||
toast.error("Date Work Completed is required to generate the document.");
|
||||
return;
|
||||
}
|
||||
const ts = fmtDocNow();
|
||||
try {
|
||||
// Prerequisites first; only mark Generated after completedDate/address persist.
|
||||
await syncCompletedDateIfChanged();
|
||||
await syncAddressIfChanged();
|
||||
await persist({ generatedAt: ts }, "Yes");
|
||||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
} catch (error) {
|
||||
// Keep Draft UI; refresh so any partially persisted date/address is authoritative.
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadPdf() {
|
||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||
}
|
||||
|
||||
return {
|
||||
handleMarkNotRequired,
|
||||
handleUndo,
|
||||
handleSaveDraft,
|
||||
handleGenerate,
|
||||
handleDownloadPdf,
|
||||
};
|
||||
}
|
||||
157
src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
Normal file
157
src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
buildCompDocAddressLine,
|
||||
createCompDocDialogActions,
|
||||
} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions";
|
||||
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 { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
|
||||
type UseCompDocDialogParams = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
data?: CompDocData;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function useCompDocDialog({
|
||||
row,
|
||||
open,
|
||||
data,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
}: UseCompDocDialogParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const locId = String(row.locationId || "");
|
||||
const isMockLocation = locId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation || !locId ? undefined : locId,
|
||||
);
|
||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
||||
|
||||
const [notes, setNotes] = useState("");
|
||||
const [docState, setDocState] = useState<DocState>("draft");
|
||||
const [generatedAt, setGeneratedAt] = useState("");
|
||||
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||
const [editAddress, setEditAddress] = useState("");
|
||||
const seedKeyRef = useRef<string | null>(null);
|
||||
const addressSeededRef = useRef(false);
|
||||
const addressTouchedRef = useRef(false);
|
||||
|
||||
// Seed once per open/WO — row/detail refetches must not wipe in-progress edits.
|
||||
// Address waits for authoritative locationDetail (when a real location id exists)
|
||||
// and still accepts that first response without overwriting later user edits.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
seedKeyRef.current = null;
|
||||
addressSeededRef.current = false;
|
||||
addressTouchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(row.id);
|
||||
if (seedKeyRef.current !== key) {
|
||||
seedKeyRef.current = key;
|
||||
addressSeededRef.current = false;
|
||||
addressTouchedRef.current = false;
|
||||
setNotes(data?.notes ?? "");
|
||||
setDocState(docStateOf(row.docStatus));
|
||||
setGeneratedAt(data?.generatedAt ?? "");
|
||||
setEditDateCompleted(row.completedDate || "");
|
||||
}
|
||||
|
||||
if (addressTouchedRef.current || addressSeededRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
|
||||
if (awaitingLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
|
||||
addressSeededRef.current = true;
|
||||
}, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
|
||||
|
||||
const handleAddressChange = (value: string) => {
|
||||
addressTouchedRef.current = true;
|
||||
setEditAddress(value);
|
||||
};
|
||||
|
||||
const amazonRep = row.pocName || "";
|
||||
const seaRep = row.tech || "";
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
const isNotRequired = docState === "not-required";
|
||||
const isGenerated = docState === "generated";
|
||||
|
||||
const effectiveStreet = editAddress.trim() || site.street;
|
||||
const docFields: DocFields = {
|
||||
street: effectiveStreet,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zip,
|
||||
dateCompleted: editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
};
|
||||
|
||||
const previewSite = useMemo(
|
||||
() => ({
|
||||
...site,
|
||||
street: effectiveStreet,
|
||||
fullAddress:
|
||||
[effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
|
||||
effectiveStreet ||
|
||||
"—",
|
||||
}),
|
||||
[site, effectiveStreet],
|
||||
);
|
||||
|
||||
const actions = createCompDocDialogActions({
|
||||
row,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
editAddress,
|
||||
docState,
|
||||
docFields,
|
||||
site,
|
||||
locId,
|
||||
isMockLocation,
|
||||
locationDetail,
|
||||
queryClient,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
setDocState,
|
||||
setGeneratedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
site: previewSite,
|
||||
notes,
|
||||
setNotes,
|
||||
docState,
|
||||
generatedAt,
|
||||
editAddress,
|
||||
setEditAddress: handleAddressChange,
|
||||
editDateCompleted,
|
||||
setEditDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
cfg,
|
||||
isNotRequired,
|
||||
isGenerated,
|
||||
...actions,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { useMemo, 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";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
|
||||
|
||||
type UploadMutate = (input: {
|
||||
file: File;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
function revokeBlobUrl(url: string | undefined) {
|
||||
if (url?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
||||
const classified = classifyUpload(request.file);
|
||||
return {
|
||||
id: request.localId,
|
||||
name: request.file.name,
|
||||
fileType: classified.fileType,
|
||||
url: URL.createObjectURL(request.file),
|
||||
category: classified.category,
|
||||
purpose: null,
|
||||
progress: 0,
|
||||
uploadStatus: "uploading" as const,
|
||||
sourceFile: request.file,
|
||||
signal: request.signal,
|
||||
};
|
||||
}
|
||||
|
||||
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
|
||||
return item.id === request.localId && item.signal === request.signal;
|
||||
}
|
||||
|
||||
export function useMediaUploadSession(
|
||||
detail: WorkOrderBoardDetail | undefined,
|
||||
uploadMutateAsync: UploadMutate,
|
||||
) {
|
||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||
|
||||
const serverUploads = useMemo(
|
||||
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
||||
[detail?.media],
|
||||
);
|
||||
|
||||
const uploads = useMemo(() => {
|
||||
const serverIds = new Set(serverUploads.map((item) => item.id));
|
||||
const pendingLocals = localUploads.filter(
|
||||
(item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed",
|
||||
);
|
||||
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
||||
}, [serverUploads, localUploads]);
|
||||
|
||||
function startUploads(requests: MediaUploadRequest[]) {
|
||||
setLocalUploads((prev) => {
|
||||
const next = [...prev];
|
||||
for (const request of requests) {
|
||||
const entry = toLocalUpload(request);
|
||||
const index = next.findIndex((item) => item.id === request.localId);
|
||||
if (index >= 0) {
|
||||
const previous = next[index];
|
||||
revokeBlobUrl(previous?.url);
|
||||
next[index] = entry;
|
||||
} else {
|
||||
next.push(entry);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
for (const request of requests) {
|
||||
void uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
onProgress: (percent) => {
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
})
|
||||
.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));
|
||||
});
|
||||
})
|
||||
.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));
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
? {
|
||||
...item,
|
||||
uploadStatus: "failed",
|
||||
errorMessage: error.message || "Upload failed",
|
||||
progress: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function retryUpload(localId: string, signal: AbortSignal) {
|
||||
const target = localUploads.find((item) => item.id === localId);
|
||||
if (!target?.sourceFile) return;
|
||||
startUploads([
|
||||
{
|
||||
localId,
|
||||
file: target.sourceFile,
|
||||
signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeLocal(id: string) {
|
||||
setLocalUploads((prev) => {
|
||||
const dropped = prev.filter((item) => item.id === id);
|
||||
for (const item of dropped) {
|
||||
revokeBlobUrl(item.url);
|
||||
}
|
||||
return prev.filter((item) => item.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
function isLocal(id: string) {
|
||||
return localUploads.some((item) => item.id === id);
|
||||
}
|
||||
|
||||
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
|
||||
}
|
||||
|
|
@ -3,13 +3,11 @@ 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 { 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 { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
|
@ -18,48 +16,54 @@ import {
|
|||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
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 { 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 [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||
const filters = useWorkOrdersListFilters();
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
const { patchField } = useWorkOrderTableMutations({
|
||||
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 totalCount = tableData.totalCount;
|
||||
const scopeTotalCount = tableData.scopeTotalCount;
|
||||
|
||||
const activeMediaRow = useMemo(() => {
|
||||
if (!mediaRow) return null;
|
||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||
}, [mediaRow, tableData.rows]);
|
||||
|
||||
const activeDocRow = useMemo(
|
||||
() => resolveActiveRow(docRow, tableData.rows),
|
||||
[docRow, tableData.rows],
|
||||
);
|
||||
const activeMediaRow = useMemo(
|
||||
() => resolveActiveRow(mediaRow, tableData.rows),
|
||||
[mediaRow, tableData.rows],
|
||||
);
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
|
|
@ -88,14 +92,14 @@ export default function WorkOrdersListPage() {
|
|||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={user?.id}
|
||||
filteredCount={totalCount}
|
||||
scopeTotalCount={scopeTotalCount}
|
||||
filteredCount={tableData.totalCount}
|
||||
scopeTotalCount={tableData.scopeTotalCount}
|
||||
/>
|
||||
|
||||
{filters.advApplied != null && (
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={filters.advApplied}
|
||||
totalCount={totalCount}
|
||||
totalCount={tableData.totalCount}
|
||||
sites={locations}
|
||||
users={users}
|
||||
onClearChip={filters.clearAdvancedFilterChip}
|
||||
|
|
@ -128,40 +132,48 @@ export default function WorkOrdersListPage() {
|
|||
tableData.setSelectedId(row.id);
|
||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||
}}
|
||||
onOpenCompDoc={(row) => {
|
||||
if (!hasCompletionDocService(row.pm)) return;
|
||||
setDocRow(row);
|
||||
}}
|
||||
onOpenMedia={setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={patchField}
|
||||
/>
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={locations}
|
||||
<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={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setMediaRow(null);
|
||||
}}
|
||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
||||
onChange={(files) => {
|
||||
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
|
||||
patchField(activeMediaRow.id, { mediaCount: files.length });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,12 +115,46 @@ export const workOrderBoardDocumentsApi = {
|
|||
uploadMedia: async (
|
||||
workOrderId: string | number,
|
||||
file: File,
|
||||
category: UploadableMediaCategory,
|
||||
category?: UploadableMediaCategory,
|
||||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
category: UploadableMediaCategory,
|
||||
workOrderVersion: string,
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
if (!workOrderVersion) {
|
||||
throw new BoardPatchValidationError(
|
||||
"WorkOrderVersionRequired",
|
||||
"workOrderVersion is required to update media category.",
|
||||
);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
formData.append("workOrderVersion", workOrderVersion);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
|
|
@ -129,7 +163,18 @@ export const workOrderBoardDocumentsApi = {
|
|||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise<void> => {
|
||||
deleteMedia: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
workOrderVersion: string,
|
||||
): Promise<void> => {
|
||||
if (!workOrderVersion) {
|
||||
throw new BoardPatchValidationError(
|
||||
"WorkOrderVersionRequired",
|
||||
"workOrderVersion is required to delete media.",
|
||||
);
|
||||
}
|
||||
|
||||
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
throw new BoardPatchValidationError(
|
||||
|
|
@ -139,6 +184,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
searchParams: { workOrderVersion },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
28
src/domain/work-orders/mappers/doc-status-mapper.ts
Normal file
28
src/domain/work-orders/mappers/doc-status-mapper.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
/** UI uses `NN`; board/API enum uses `NotNeeded` (also numeric 3). */
|
||||
export function mapDocStatusToApi(value: string): string {
|
||||
if (value === "NN") {
|
||||
return "NotNeeded";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readDocStatusFromRecord(record: Record<string, unknown>): CompletionDocStatus {
|
||||
const rawValue =
|
||||
record.docStatus ?? record.DocStatus ?? record.completionDoc ?? record.CompletionDoc;
|
||||
if (typeof rawValue === "number") {
|
||||
if (rawValue === 1) return "Yes";
|
||||
if (rawValue === 2) return "No";
|
||||
if (rawValue === 3) return "NN";
|
||||
}
|
||||
if (typeof rawValue === "string") {
|
||||
if (rawValue === "Yes" || rawValue === "No" || rawValue === "NN") {
|
||||
return rawValue;
|
||||
}
|
||||
if (rawValue === "NotNeeded") {
|
||||
return "NN";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||
|
||||
export type BoardFieldOp = {
|
||||
field: WorkOrderBoardEditableField;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function mapDocStatusToApi(value: string): string {
|
||||
if (value === "NN") {
|
||||
return "NotNeeded";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export { mapDocStatusToApi };
|
||||
|
||||
type ScalarBoardOpRule = {
|
||||
key: keyof WorkOrderTablePatch;
|
||||
|
|
@ -31,6 +27,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
|||
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
|
||||
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
|
||||
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
|
||||
{ key: "completedDate", field: "completedDate", toValue: (value) => value as string },
|
||||
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
||||
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
|||
dueDate: draft.dueDate,
|
||||
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
|
||||
completedDate: "",
|
||||
docStatus: "",
|
||||
assignedTo: draft.assigneeName,
|
||||
description: buildDescription(draft),
|
||||
po: "",
|
||||
|
|
|
|||
|
|
@ -147,9 +147,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
|
|||
|
||||
export type UploadableMediaCategory = "Before" | "After" | "Extra";
|
||||
|
||||
export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
|
||||
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
|
||||
const form = new FormData();
|
||||
form.append("category", category);
|
||||
if (category) {
|
||||
form.append("category", category);
|
||||
}
|
||||
form.append("file", file);
|
||||
return form;
|
||||
}
|
||||
|
|
@ -257,7 +259,12 @@ 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,
|
||||
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
||||
uploadStatus: "done" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type {
|
||||
EmployeeOption,
|
||||
|
|
@ -14,6 +15,8 @@ import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-comm
|
|||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
export { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
|
@ -214,6 +217,7 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-
|
|||
|
||||
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||
const item = asRecord(raw);
|
||||
const authorId = readString(item, "authorId", "AuthorId") || null;
|
||||
const mentionsRaw = item.mentions ?? item.Mentions;
|
||||
const mentions = Array.isArray(mentionsRaw)
|
||||
? mentionsRaw
|
||||
|
|
@ -226,7 +230,8 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
|||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||
commentType: readString(item, "commentType", "CommentType"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
||||
authorId,
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
recordType: readString(item, "recordType", "RecordType") || null,
|
||||
mentions,
|
||||
|
|
@ -309,6 +314,8 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
|||
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
|
||||
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
|
||||
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
|
||||
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
||||
docStatus: readDocStatusFromRecord(wo),
|
||||
source: readString(wo, "source", "Source"),
|
||||
...(contactIds !== undefined ? { contactIds } : {}),
|
||||
...(categoryIds !== undefined ? { categoryIds } : {}),
|
||||
|
|
@ -342,6 +349,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
|
|||
dueDate: detail.dueDate,
|
||||
scheduledDate: detail.scheduledDate,
|
||||
completedDate: detail.completedDate,
|
||||
docStatus: detail.docStatus,
|
||||
assignedTo: detail.assignedTo,
|
||||
description: detail.description,
|
||||
po: detail.po,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder
|
|||
vendorNTE: values.vendorNTE,
|
||||
scheduledDate: values.scheduledDate,
|
||||
completedDate: values.completedDate,
|
||||
docStatus: values.docStatus ?? "",
|
||||
source: values.source,
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
|
|
|
|||
8
src/domain/work-orders/types/work-order-board-patch.ts
Normal file
8
src/domain/work-orders/types/work-order-board-patch.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||
export interface WorkOrderBoardPatchPayload {
|
||||
field: string;
|
||||
value?: string | null;
|
||||
workOrderVersion: string;
|
||||
dispatchVersion?: string | null;
|
||||
primaryDispatchId?: number | null;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
|
||||
import type { WorkOrderDispatchSummary } from "./dispatch";
|
||||
import type { CompletionDocStatus } from "./work-order-table-row";
|
||||
|
||||
export interface WorkOrderComment {
|
||||
id: string | number;
|
||||
|
|
@ -51,6 +52,8 @@ export interface WorkOrderDetail {
|
|||
vendorNTE: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
/** Completion-document status (API DocStatus); persisted via board PATCH. */
|
||||
docStatus: CompletionDocStatus;
|
||||
source: string;
|
||||
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
|
||||
contactIds?: number[];
|
||||
|
|
@ -72,6 +75,8 @@ export interface WorkOrderFormValues {
|
|||
dueDate: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
/** Optional: absent from the wizard's zod-validated shape, present once loaded from the server. */
|
||||
docStatus?: CompletionDocStatus;
|
||||
assignedTo: string;
|
||||
description: string;
|
||||
po: string;
|
||||
|
|
@ -99,6 +104,7 @@ export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
|
|||
dueDate: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
docStatus: "",
|
||||
assignedTo: "",
|
||||
description: "",
|
||||
po: "",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ export interface MediaFile {
|
|||
fileType: FileCategory;
|
||||
url: string;
|
||||
category: MediaCategory;
|
||||
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
|
||||
purpose?: "Before" | "After" | "Extra" | null;
|
||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||
isLegacy?: boolean;
|
||||
/** 0–100 while uploading; undefined when idle/complete. */
|
||||
progress?: number;
|
||||
uploadStatus?: "uploading" | "failed" | "done";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const MEDIA_CATEGORIES: MediaCategory[] = [
|
||||
|
|
|
|||
|
|
@ -51,7 +51,20 @@ export interface UpdateWorkOrderCommentInput {
|
|||
|
||||
export interface UploadWorkOrderMediaInput {
|
||||
file: File;
|
||||
category?: UploadableMediaCategory;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
export interface UpdateWorkOrderMediaCategoryInput {
|
||||
mediaId: string | number;
|
||||
category: UploadableMediaCategory;
|
||||
workOrderVersion: string;
|
||||
}
|
||||
|
||||
export interface DeleteWorkOrderMediaInput {
|
||||
mediaId: string | number;
|
||||
workOrderVersion: string;
|
||||
}
|
||||
|
||||
export interface UploadCompletionDocInput {
|
||||
|
|
@ -192,12 +205,12 @@ export function useUploadWorkOrderMedia(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
|
||||
workOrdersApi.uploadMedia(workOrderId, file, category),
|
||||
mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) =>
|
||||
workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("File uploaded");
|
||||
toast.success("File uploaded — assign a category");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to upload file");
|
||||
|
|
@ -205,13 +218,33 @@ export function useUploadWorkOrderMedia(
|
|||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkOrderMedia(
|
||||
export function useUpdateWorkOrderMediaCategory(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, string | number> {
|
||||
): UseMutationResult<WorkOrderBoardMediaItem, Error, UpdateWorkOrderMediaCategoryInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
|
||||
mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) =>
|
||||
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("Category saved");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update category");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkOrderMedia(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, DeleteWorkOrderMediaInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) =>
|
||||
workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
|
|
@ -18,6 +19,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export type { WorkOrderTablePatch };
|
||||
|
|
@ -28,6 +30,65 @@ export type WorkOrderTablePatchHandlers = {
|
|||
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
|
||||
};
|
||||
|
||||
type PatchResult =
|
||||
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
|
||||
|
||||
async function applyBoardFieldOps(
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
|
||||
): Promise<WorkOrderTableRow> {
|
||||
const ops = expandTablePatchToBoardFieldOps(patch);
|
||||
if (ops.length === 0) {
|
||||
throw new BoardPatchValidationError(
|
||||
"NoPersistableFields",
|
||||
"These changes cannot be saved on the board. Refresh and edit a supported field.",
|
||||
);
|
||||
}
|
||||
|
||||
const current = getRow?.(id);
|
||||
if (!current?.rowVersion) {
|
||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||
}
|
||||
|
||||
let latest = current;
|
||||
let completedOps = 0;
|
||||
for (const op of ops) {
|
||||
try {
|
||||
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||
completedOps += 1;
|
||||
} catch (error) {
|
||||
if (completedOps > 0) {
|
||||
const field = op.field;
|
||||
const partial = new Error(
|
||||
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
|
||||
throw partial;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
|
||||
export async function runWorkOrderTablePatch(
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
|
||||
): Promise<PatchResult> {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return { kind: "local", patch };
|
||||
}
|
||||
|
||||
const boardRow = await applyBoardFieldOps(id, patch, getRow);
|
||||
return { kind: "board", row: boardRow };
|
||||
}
|
||||
|
||||
export function useWorkOrderTableMutations(
|
||||
onPatchOrHandlers:
|
||||
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
|
||||
|
|
@ -44,53 +105,15 @@ export function useWorkOrderTableMutations(
|
|||
|
||||
const { onPatch, clearPatch, getRow } = handlers;
|
||||
const queryClient = useQueryClient();
|
||||
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
};
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return { kind: "local" as const, patch };
|
||||
}
|
||||
|
||||
const ops = expandTablePatchToBoardFieldOps(patch);
|
||||
if (ops.length === 0) {
|
||||
throw new BoardPatchValidationError(
|
||||
"NoPersistableFields",
|
||||
"These changes cannot be saved on the board. Refresh and edit a supported field.",
|
||||
);
|
||||
}
|
||||
|
||||
const current = getRow?.(id);
|
||||
if (!current?.rowVersion) {
|
||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||
}
|
||||
|
||||
let latest = current;
|
||||
let completedOps = 0;
|
||||
for (const op of ops) {
|
||||
try {
|
||||
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||
completedOps += 1;
|
||||
} catch (error) {
|
||||
if (completedOps > 0) {
|
||||
const field = op.field;
|
||||
const partial = new Error(
|
||||
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
|
||||
throw partial;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "board" as const, row: latest };
|
||||
},
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
},
|
||||
|
|
@ -127,8 +150,12 @@ export function useWorkOrderTableMutations(
|
|||
patchMutation.mutate({ id, patch });
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
patchMutation.mutateAsync({ id, patch });
|
||||
|
||||
return {
|
||||
patchField,
|
||||
patchFieldAsync,
|
||||
isPending: patchMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
/** CompDoc is gated on a selected primary service (`pm`). */
|
||||
export function hasCompletionDocService(pm: string | null | undefined): boolean {
|
||||
return Boolean(pm?.trim());
|
||||
}
|
||||
|
|
@ -17,15 +17,12 @@ const SERVICE_PHRASE: Record<string, string> = {
|
|||
"Assess Window Leaks": "Window Leak Assessment",
|
||||
};
|
||||
|
||||
const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
|
||||
|
||||
function hashNum(s: string, mod: number): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return h % mod;
|
||||
}
|
||||
|
||||
export function serviceTitle(row: Pick<WorkOrderTableRow, "pm" | "woNumber">): string {
|
||||
const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
|
||||
return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
|
||||
/**
|
||||
* Title derived only from the selected service (`pm`).
|
||||
* Never fabricates maintenance-cycle metadata from the work-order number.
|
||||
*/
|
||||
export function serviceTitle(row: Pick<WorkOrderTableRow, "pm">): string {
|
||||
const service = row.pm.trim();
|
||||
const phrase = SERVICE_PHRASE[service] ?? service;
|
||||
return `Amer-Building ${phrase}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
|
|
@ -8,6 +12,19 @@ export function isWorkOrderFileTooLarge(
|
|||
return file.size > maxBytes;
|
||||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
|
|
|||
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal file
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Serializes async work per work-order id so concurrent patches (e.g. a completion-doc
|
||||
* status save firing alongside a completed-date save) never race two overlapping
|
||||
* get-then-update round trips against the same record. Each call for a given id waits
|
||||
* for the previous one to settle before starting its own fetch-and-update cycle.
|
||||
*/
|
||||
export function createWorkOrderPatchQueue() {
|
||||
const chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
return function enqueue<T>(id: string | number, run: () => Promise<T>): Promise<T> {
|
||||
const key = String(id);
|
||||
const previous = chains.get(key) ?? Promise.resolve();
|
||||
const settled = previous.catch(() => undefined).then(run);
|
||||
chains.set(
|
||||
key,
|
||||
settled.catch(() => undefined),
|
||||
);
|
||||
return settled;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CompDocCell } from "@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell";
|
||||
|
||||
describe("CompDocCell service gate", () => {
|
||||
it("shows a disabled placeholder when no service is selected", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<CompDocCell status="No" woType="PM" pm="" onOpen={onOpen} onOpenMedia={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Select service")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /draft|view doc|n\/n/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens CompDoc when a service is selected", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
<CompDocCell
|
||||
status="No"
|
||||
woType="PM"
|
||||
pm="HVAC Inspection"
|
||||
onOpen={onOpen}
|
||||
onOpenMedia={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: /draft/i }).click();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
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";
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
|
||||
showCompDocDraftSavedToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const SCHEDULED_ON = "2026-06-01";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: SCHEDULED_ON,
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "mock-loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "Vendor Co",
|
||||
tech: "Tech One",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "Amazon Rep",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "PM — SEA1",
|
||||
priority: "Sev 3",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||
const onSave = vi.fn();
|
||||
const onDateCompletedChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onSave.mockReset();
|
||||
onDateCompletedChange.mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
function renderDialog(row: WorkOrderTableRow) {
|
||||
// Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
|
||||
const patches: Array<Partial<WorkOrderTableRow>> = [];
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompDocDialog
|
||||
row={row}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={(data, status) => {
|
||||
onSave(data, status);
|
||||
patches.push({ docStatus: status });
|
||||
}}
|
||||
onDateCompletedChange={(date) => {
|
||||
onDateCompletedChange(date);
|
||||
patches.push({ completedDate: date });
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return { patches };
|
||||
}
|
||||
|
||||
function changeDateCompleted(value: string) {
|
||||
const dateInput = document.querySelector('input[type="date"]');
|
||||
expect(dateInput).not.toBeNull();
|
||||
fireEvent.change(dateInput!, { target: { value } });
|
||||
}
|
||||
|
||||
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", async () => {
|
||||
const row = baseRow();
|
||||
const { patches } = renderDialog(row);
|
||||
|
||||
changeDateCompleted("2026-07-15");
|
||||
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
|
||||
expect(patches).toEqual([{ completedDate: "2026-07-15" }, { docStatus: "No" }]);
|
||||
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||
expect(row.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("Generate emits completedDate before doc Yes without auto-setting lifecycle Completed", async () => {
|
||||
const row = baseRow({ completedDate: "2026-07-01" });
|
||||
const { patches } = renderDialog(row);
|
||||
|
||||
changeDateCompleted("2026-07-20");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ generatedAt: expect.any(String) }),
|
||||
"Yes",
|
||||
);
|
||||
expect(patches).toEqual([{ completedDate: "2026-07-20" }, { docStatus: "Yes" }]);
|
||||
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||
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>,
|
||||
);
|
||||
|
||||
changeDateCompleted("2026-07-20");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(invalidateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const uploadMutateAsync = vi.fn();
|
||||
const deleteMutate = vi.fn();
|
||||
const categorizeMutate = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({
|
||||
data: {
|
||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
||||
media: [
|
||||
{
|
||||
id: "11",
|
||||
fileName: "before.jpg",
|
||||
url: "https://example.com/before.jpg",
|
||||
category: "Before",
|
||||
isLegacy: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
audit: [],
|
||||
completion: {},
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||
useUploadWorkOrderMedia: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: uploadMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }),
|
||||
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
function baseRow(): WorkOrderTableRow {
|
||||
return {
|
||||
id: 7,
|
||||
woNumber: "WO-7",
|
||||
site: "SEA1",
|
||||
type: "Emergency",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "loc-1",
|
||||
pm: "",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 1,
|
||||
title: "Emergency",
|
||||
priority: "Sev 1",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "ROW-VER==",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MediaUploadModal API wiring", () => {
|
||||
beforeEach(() => {
|
||||
uploadMutateAsync.mockReset();
|
||||
deleteMutate.mockReset();
|
||||
categorizeMutate.mockReset();
|
||||
});
|
||||
|
||||
it("renders persisted media from board detail (refresh-safe)", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||
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==",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const locationDetailState = vi.hoisted(() => ({
|
||||
data: undefined as Location | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: () => ({ data: locationDetailState.data }),
|
||||
}));
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "loc-real-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "Vendor Co",
|
||||
tech: "Tech One",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "Amazon Rep",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "PM — SEA1",
|
||||
priority: "Sev 3",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("useCompDocDialog address seeding", () => {
|
||||
beforeEach(() => {
|
||||
locationDetailState.data = undefined;
|
||||
});
|
||||
|
||||
it("seeds address from the first authoritative locationDetail without locking on the empty fallback", async () => {
|
||||
const row = baseRow();
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useCompDocDialog({
|
||||
row,
|
||||
open: true,
|
||||
onSave: vi.fn(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.editAddress).toBe("");
|
||||
|
||||
locationDetailState.data = {
|
||||
id: "loc-real-1",
|
||||
name: "SEA1",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Seattle",
|
||||
state: "WA",
|
||||
zipCode: "98101",
|
||||
} as Location;
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.editAddress).toContain("3811 Distribution Dr");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite address after the dispatcher edits it", async () => {
|
||||
locationDetailState.data = {
|
||||
id: "loc-real-1",
|
||||
name: "SEA1",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Seattle",
|
||||
state: "WA",
|
||||
zipCode: "98101",
|
||||
} as Location;
|
||||
|
||||
const row = baseRow();
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useCompDocDialog({
|
||||
row,
|
||||
open: true,
|
||||
onSave: vi.fn(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.editAddress).toContain("3811 Distribution Dr");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setEditAddress("Dispatcher typed address");
|
||||
});
|
||||
|
||||
locationDetailState.data = {
|
||||
id: "loc-real-1",
|
||||
name: "SEA1",
|
||||
address: "999 Other St",
|
||||
city: "Seattle",
|
||||
state: "WA",
|
||||
zipCode: "98101",
|
||||
} as Location;
|
||||
rerender();
|
||||
|
||||
expect(result.current.editAddress).toBe("Dispatcher typed address");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
|
||||
function makeFile(name = "photo.jpg") {
|
||||
return new File(["bytes"], name, { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
describe("useMediaUploadSession retry / cancel", () => {
|
||||
it("retry upserts the same localId instead of appending a duplicate", async () => {
|
||||
let rejectFirst!: (error: Error) => void;
|
||||
const uploadMutateAsync = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||
const firstController = new AbortController();
|
||||
const localId = "local-1";
|
||||
const file = makeFile();
|
||||
|
||||
act(() => {
|
||||
result.current.startUploads([
|
||||
{
|
||||
localId,
|
||||
file,
|
||||
signal: firstController.signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rejectFirst(new Error("network"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
|
||||
const retryController = new AbortController();
|
||||
act(() => {
|
||||
result.current.retryUpload(localId, retryController.signal);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads).toHaveLength(1);
|
||||
expect(result.current.uploads[0]?.id).toBe(localId);
|
||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||
});
|
||||
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
||||
});
|
||||
|
||||
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
|
||||
let rejectFirst!: (error: Error) => void;
|
||||
const uploadMutateAsync = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockImplementation(
|
||||
({ signal }: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener("abort", () => {
|
||||
const error = new Error("aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||
const localId = "local-retry";
|
||||
const file = makeFile("retry.jpg");
|
||||
|
||||
act(() => {
|
||||
result.current.startUploads([
|
||||
{
|
||||
localId,
|
||||
file,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rejectFirst(new Error("upload failed"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
|
||||
const retryController = new AbortController();
|
||||
act(() => {
|
||||
result.current.retryUpload(localId, retryController.signal);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
|
||||
retryController.abort();
|
||||
result.current.removeLocal(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);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-15",
|
||||
completedDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
|
|
|
|||
|
|
@ -234,7 +234,13 @@ describe("CompDocCell slice affordances", () => {
|
|||
|
||||
it("shows View Doc when onOpen is provided", () => {
|
||||
renderWithProviders(
|
||||
<CompDocCell status="Yes" woType="PM" onOpen={vi.fn()} onOpenMedia={vi.fn()} />,
|
||||
<CompDocCell
|
||||
status="Yes"
|
||||
woType="PM"
|
||||
pm="HVAC Inspection"
|
||||
onOpen={vi.fn()}
|
||||
onOpenMedia={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ describe("workOrdersApi.update", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi completedDate board contract", () => {
|
||||
it("does not expose updateCompletedDate — completedDate uses board PATCH", () => {
|
||||
expect("updateCompletedDate" in workOrdersApi).toBe(false);
|
||||
expect(API_PATHS.workOrder.boardPatch(55)).toBe("workorders/55/board");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.fetchDispatchers", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
|
|
@ -496,12 +503,55 @@ describe("workOrdersApi.uploadMedia", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.updateMediaCategory", () => {
|
||||
beforeEach(() => {
|
||||
apiPatchFn.mockReset();
|
||||
});
|
||||
|
||||
it("PATCHes FormData with category and workOrderVersion", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 12,
|
||||
category: 1,
|
||||
url: "https://cdn.example.com/before.jpg",
|
||||
fileName: "before.jpg",
|
||||
uploadedAt: "2026-07-16T12:00:00Z",
|
||||
isLegacy: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const item = await workOrdersApi.updateMediaCategory(10, 12, "Before", "AAAAAAA=");
|
||||
|
||||
expect(apiPatchFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.mediaItem(10, 12),
|
||||
expect.objectContaining({ throwHttpErrors: false }),
|
||||
);
|
||||
const body = apiPatchFn.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect(body.get("category")).toBe("Before");
|
||||
expect(body.get("workOrderVersion")).toBe("AAAAAAA=");
|
||||
expect(item.category).toBe("Before");
|
||||
});
|
||||
|
||||
it("rejects missing workOrderVersion client-side", async () => {
|
||||
const { BoardPatchValidationError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(workOrdersApi.updateMediaCategory(10, 12, "After", "")).rejects.toMatchObject({
|
||||
name: "BoardPatchValidationError",
|
||||
code: "WorkOrderVersionRequired",
|
||||
});
|
||||
expect(apiPatchFn).not.toHaveBeenCalled();
|
||||
expect(BoardPatchValidationError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.deleteMedia", () => {
|
||||
beforeEach(() => {
|
||||
apiDeleteFn.mockReset();
|
||||
});
|
||||
|
||||
it("DELETEs media item and accepts 204", async () => {
|
||||
it("DELETEs media item with workOrderVersion query and accepts 204", async () => {
|
||||
apiDeleteFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
|
|
@ -510,18 +560,31 @@ describe("workOrdersApi.deleteMedia", () => {
|
|||
},
|
||||
});
|
||||
|
||||
await workOrdersApi.deleteMedia(10, 12);
|
||||
await workOrdersApi.deleteMedia(10, 12, "AAAAAAA=");
|
||||
|
||||
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
|
||||
searchParams: { workOrderVersion: "AAAAAAA=" },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing workOrderVersion client-side", async () => {
|
||||
const { BoardPatchValidationError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(workOrdersApi.deleteMedia(10, 12, "")).rejects.toMatchObject({
|
||||
name: "BoardPatchValidationError",
|
||||
code: "WorkOrderVersionRequired",
|
||||
});
|
||||
expect(apiDeleteFn).not.toHaveBeenCalled();
|
||||
expect(BoardPatchValidationError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects legacy media ids client-side", async () => {
|
||||
const { BoardPatchValidationError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf(
|
||||
await expect(workOrdersApi.deleteMedia(10, -1, "AAAAAAA=")).rejects.toBeInstanceOf(
|
||||
BoardPatchValidationError,
|
||||
);
|
||||
expect(apiDeleteFn).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ describe("mapWorkOrderBoardDetail", () => {
|
|||
expect(detail.info.docStatus).toBe("No");
|
||||
expect(detail.info.company).toBe("ACME HVAC");
|
||||
expect(detail.info.tech).toBe("Mike Tech");
|
||||
expect(detail.info.pm).toBe("HVAC PM");
|
||||
expect(detail.info.location).toBe("Main Warehouse");
|
||||
|
||||
expect(detail.completion.docStatus).toBe("No");
|
||||
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
|
||||
|
|
|
|||
|
|
@ -135,4 +135,18 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
|
|||
expect(formData.getAll("ContactIds")).toEqual([]);
|
||||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps completedDate on full EditWorkorder form and omits DocStatus (board field)", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
createdDate: "2026-01-15",
|
||||
completedDate: "2026-07-20",
|
||||
docStatus: "NN" as const,
|
||||
};
|
||||
|
||||
const formData = mapWorkOrderFormToUpdateFormData(1, values);
|
||||
|
||||
expect(formData.get("CompletedDate")).toBe("2026-07-20");
|
||||
expect(formData.has("DocStatus")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapDocStatusToApi,
|
||||
mapWorkOrderDetail,
|
||||
mapWorkOrderDetailToFormValues,
|
||||
mapWorkOrderDispatchSummary,
|
||||
mapWorkOrderTableRow,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
|
@ -23,6 +26,40 @@ describe("mapWorkOrderDispatchSummary", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderDetail completion-document status", () => {
|
||||
it("reads DocStatus from the API payload so it round-trips into form values", () => {
|
||||
const detail = mapWorkOrderDetail({
|
||||
Id: 42,
|
||||
CompletedDate: "2026-07-20",
|
||||
DocStatus: "Yes",
|
||||
});
|
||||
|
||||
expect(detail.docStatus).toBe("Yes");
|
||||
|
||||
const formValues = mapWorkOrderDetailToFormValues(detail);
|
||||
expect(formValues.docStatus).toBe("Yes");
|
||||
expect(formValues.completedDate).toBe("2026-07-20");
|
||||
});
|
||||
|
||||
it("maps API NotNeeded to UI NN", () => {
|
||||
expect(mapWorkOrderDetail({ Id: 1, DocStatus: "NotNeeded" }).docStatus).toBe("NN");
|
||||
expect(mapWorkOrderDetail({ Id: 2, docStatus: 3 }).docStatus).toBe("NN");
|
||||
});
|
||||
|
||||
it("falls back to an empty doc status for unknown/missing values", () => {
|
||||
const detail = mapWorkOrderDetail({ Id: 1 });
|
||||
expect(detail.docStatus).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapDocStatusToApi", () => {
|
||||
it("maps NN to NotNeeded and keeps Yes/No", () => {
|
||||
expect(mapDocStatusToApi("NN")).toBe("NotNeeded");
|
||||
expect(mapDocStatusToApi("Yes")).toBe("Yes");
|
||||
expect(mapDocStatusToApi("No")).toBe("No");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapUpliftRequest", () => {
|
||||
it("maps decided uplift fields from PascalCase payload", () => {
|
||||
const result = mapUpliftRequest({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const patchBoardField = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { runWorkOrderTablePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
const BASE_ROW = {
|
||||
id: 99,
|
||||
rowVersion: "AAAAAAA=",
|
||||
completedDate: "",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("runWorkOrderTablePatch completedDate", () => {
|
||||
beforeEach(() => {
|
||||
patchBoardField.mockReset();
|
||||
patchBoardField.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
completedDate: "2026-07-30",
|
||||
rowVersion: "BBBBBBB=",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists completedDate via board PATCH (not EditWorkorder)", async () => {
|
||||
const result = await runWorkOrderTablePatch(
|
||||
99,
|
||||
{ completedDate: "2026-07-30" },
|
||||
() => BASE_ROW,
|
||||
);
|
||||
|
||||
expect(patchBoardField).toHaveBeenCalledTimes(1);
|
||||
expect(patchBoardField).toHaveBeenCalledWith(99, {
|
||||
field: "completedDate",
|
||||
value: "2026-07-30",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
kind: "board",
|
||||
row: expect.objectContaining({ completedDate: "2026-07-30" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
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";
|
||||
|
||||
const getById = vi.fn();
|
||||
const update = vi.fn();
|
||||
const patchBoardField = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
getById: (...args: unknown[]) => getById(...args),
|
||||
update: (...args: unknown[]) => update(...args),
|
||||
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
||||
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
status: "Open",
|
||||
po: "",
|
||||
tt: "",
|
||||
problem: "HVAC",
|
||||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
scheduledDate: "2026-06-01",
|
||||
completedDate: "",
|
||||
docStatus: "No",
|
||||
source: "PM",
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
dispatches: [],
|
||||
};
|
||||
|
||||
const BASE_ROW = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Incomplete",
|
||||
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: "AAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||
let serverState: WorkOrderDetail;
|
||||
let events: string[];
|
||||
let rowState: WorkOrderTableRow;
|
||||
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
serverState = { ...BASE_DETAIL };
|
||||
rowState = { ...BASE_ROW };
|
||||
events = [];
|
||||
|
||||
patchBoardField.mockImplementation(
|
||||
async (
|
||||
_id: unknown,
|
||||
payload: { field: string; value?: string | null; workOrderVersion: string },
|
||||
) => {
|
||||
events.push(`boardPatch:${payload.field}:start`);
|
||||
await delay(5);
|
||||
if (payload.field === "docStatus" && typeof payload.value === "string") {
|
||||
const docStatus =
|
||||
payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]);
|
||||
serverState = { ...serverState, docStatus };
|
||||
rowState = {
|
||||
...rowState,
|
||||
docStatus,
|
||||
rowVersion: `${payload.workOrderVersion}-next`,
|
||||
};
|
||||
}
|
||||
if (payload.field === "completedDate" && typeof payload.value === "string") {
|
||||
serverState = { ...serverState, completedDate: payload.value };
|
||||
rowState = {
|
||||
...rowState,
|
||||
completedDate: payload.value,
|
||||
rowVersion: `${payload.workOrderVersion}-date`,
|
||||
};
|
||||
}
|
||||
events.push(`boardPatch:${payload.field}:end`);
|
||||
return { ...rowState };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { docStatus: "NN" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
|
||||
|
||||
expect(patchBoardField).toHaveBeenCalledWith(42, {
|
||||
field: "docStatus",
|
||||
value: "NotNeeded",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(getById).not.toHaveBeenCalled();
|
||||
expect(serverState.docStatus).toBe("NN");
|
||||
});
|
||||
|
||||
it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { docStatus: "Yes" });
|
||||
result.current.patchField(42, { completedDate: "2026-07-20" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
|
||||
await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
|
||||
|
||||
expect(events).toEqual([
|
||||
"boardPatch:docStatus:start",
|
||||
"boardPatch:docStatus:end",
|
||||
"boardPatch:completedDate:start",
|
||||
"boardPatch:completedDate:end",
|
||||
]);
|
||||
|
||||
expect(getById).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
|
||||
describe("completion doc service gate", () => {
|
||||
it("rejects empty or whitespace-only pm", () => {
|
||||
expect(hasCompletionDocService("")).toBe(false);
|
||||
expect(hasCompletionDocService(" ")).toBe(false);
|
||||
expect(hasCompletionDocService(null)).toBe(false);
|
||||
expect(hasCompletionDocService(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a selected service", () => {
|
||||
expect(hasCompletionDocService("HVAC Inspection")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion doc service title", () => {
|
||||
it("uses the selected service without fabricating a frequency cycle", () => {
|
||||
expect(serviceTitle({ pm: "HVAC Inspection" })).toBe("Amer-Building HVAC Inspection");
|
||||
expect(serviceTitle({ pm: "HVAC Inspection" })).not.toMatch(/Week/);
|
||||
});
|
||||
|
||||
it("maps known service phrases without hashing the work-order number", () => {
|
||||
expect(serviceTitle({ pm: "Scrubber Pit" })).toBe("Amer-Building Scrubber Pit Augering");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
canDeleteWorkOrderMedia,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
|
|
@ -23,4 +24,17 @@ describe("work-order-media-limits", () => {
|
|||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("createWorkOrderPatchQueue", () => {
|
||||
it("serializes calls for the same id so the second never starts before the first settles", async () => {
|
||||
const enqueue = createWorkOrderPatchQueue();
|
||||
const events: string[] = [];
|
||||
|
||||
const first = enqueue(1, async () => {
|
||||
events.push("first:start");
|
||||
await delay(20);
|
||||
events.push("first:end");
|
||||
return "first";
|
||||
});
|
||||
const second = enqueue(1, async () => {
|
||||
events.push("second:start");
|
||||
await delay(1);
|
||||
events.push("second:end");
|
||||
return "second";
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]);
|
||||
});
|
||||
|
||||
it("does not block work queued for a different id", async () => {
|
||||
const enqueue = createWorkOrderPatchQueue();
|
||||
const events: string[] = [];
|
||||
|
||||
const slow = enqueue(1, async () => {
|
||||
events.push("slow:start");
|
||||
await delay(20);
|
||||
events.push("slow:end");
|
||||
});
|
||||
const fast = enqueue(2, async () => {
|
||||
events.push("fast:start");
|
||||
await delay(1);
|
||||
events.push("fast:end");
|
||||
});
|
||||
|
||||
await Promise.all([slow, fast]);
|
||||
|
||||
expect(events.indexOf("fast:end")).toBeLessThan(events.indexOf("slow:end"));
|
||||
});
|
||||
|
||||
it("still runs the next task after a prior task rejects", async () => {
|
||||
const enqueue = createWorkOrderPatchQueue();
|
||||
|
||||
await expect(
|
||||
enqueue(1, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
await expect(enqueue(1, async () => "recovered")).resolves.toBe("recovered");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue