feat(work-orders): board slide-over detail with comments, audit and media

# Conflicts:
#	src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx
#	src/app/(protected)/workorders/_components/detail/work-order-assignee-menu.tsx
#	src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx
#	src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx
#	src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx
#	src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
This commit is contained in:
Arthur Bassi 2026-07-27 11:25:49 -03:00
parent 6e99dc0af2
commit 1b5b4014d1
17 changed files with 3003 additions and 69 deletions

View file

@ -0,0 +1,550 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import {
Ban,
CheckCircle2,
Download,
FileText,
Save,
Sparkles,
Undo2,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import {
DOC_STATE_CFG,
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
import {
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar";
const FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onStatusChange?: (status: WizardWOStatus) => void;
onDateCompletedChange?: (date: string) => void;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
onSave,
onStatusChange,
onDateCompletedChange,
}: CompDocDialogProps) {
const locId = String(row.locationId || "");
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const isUploading = uploadCompletionMutation.isPending;
const pdfInputRef = useRef<HTMLInputElement>(null);
const info = boardDetail?.info ?? row;
const completion = boardDetail?.completion;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
useEffect(() => {
if (!open) return;
setNotes(data?.notes ?? "");
setDocState(docStateOf(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(info.scheduledOn || "");
}, [
open,
info.id,
info.docStatus,
info.scheduledOn,
completion?.docStatus,
data?.notes,
data?.generatedAt,
]);
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
const docFields: DocFields = {
street: site.street,
city: site.city,
state: site.state,
zip: site.zip,
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
};
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
onSave({ notes, generatedAt, ...extra }, status);
};
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
}
function handleUndo() {
setDocState("draft");
persist({}, "No");
}
function handleSaveDraft() {
persist({}, "No");
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
<CheckCircle2 size={16} /> Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}
function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
onStatusChange?.("Completed");
}
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
}
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
if (!rowVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
},
},
);
}
const handleClose = isUploading ? undefined : () => onOpenChange(false);
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1024px, calc(100vw - 32px))",
maxWidth: "none",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent
dividers={false}
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
sx={{
p: 0,
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{info.woNumber}</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span
className="font-mono"
style={{ color: "var(--color-primary)", fontWeight: 600 }}
>
#{info.woNumber}
</span>
{" · "}
{serviceTitle(info)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={() => {
if (!isUploading) onOpenChange(false);
}}
disabled={isUploading}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
{Boolean(templateUrl || signOffAttachment) && (
<div
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
style={{
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
{Boolean(templateUrl) && (
<a
href={templateUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
Open uploaded PDF
</a>
)}
</div>
)}
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={info}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
<CompDocSidebar
row={info}
site={site}
docState={docState}
generatedAt={generatedAt}
notes={notes}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}
isGenerated={isGenerated}
onNotesChange={setNotes}
onDateCompletedChange={setEditDateCompleted}
/>
</div>
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
handleUploadPdf(event.target.files);
event.target.value = "";
}}
/>
{docState === "draft" && !isDocumentsReadOnly && (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={handleMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
onClick={handleSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Upload PDF
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={handleGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
)}
{(docState === "generated" || Boolean(signOffAttachment)) &&
docState !== "not-required" && (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} />{" "}
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Replace PDF
</Button>
)}
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={handleDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
)}
{docState === "not-required" && (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
onClick={handleUndo}
>
<Undo2 size={14} /> Undo
</Button>
)}
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,234 @@
import { useRef } from "react";
import { Ban, CheckCircle2, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
type SlideOverCompletionTabProps = {
row: WorkOrderTableRow;
isReadOnly: boolean;
completion?: WorkOrderBoardCompletion;
media: MediaFile[];
mediaPending?: boolean;
completionPending?: boolean;
onUploadMedia?: (files: File[], category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
onUploadCompletionPdf?: (file: File) => void;
onOpenCompletionDoc: () => void;
};
export function SlideOverCompletionTab({
row,
isReadOnly,
completion,
media,
mediaPending = false,
completionPending = false,
onUploadMedia,
onRemoveMedia,
onUploadCompletionPdf,
onOpenCompletionDoc,
}: SlideOverCompletionTabProps) {
const pdfInputRef = useRef<HTMLInputElement>(null);
const docStatus = completion?.docStatus || row.docStatus;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
if (isMediaWO(row.type)) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Photos &amp; Videos
</span>
</div>
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
onUpload={onUploadMedia}
onRemove={onRemoveMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>
</div>
</div>
);
}
const handlePdfSelected = (files: FileList | null) => {
const file = files?.[0];
if (!file || !onUploadCompletionPdf) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
onUploadCompletionPdf(file);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<FileText size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Completion Document
</span>
</div>
{Boolean(templateUrl) && (
<a
href={templateUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
View uploaded document
</a>
)}
{isReadOnly ? (
docStatus === "Yes" ? (
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<CheckCircle2 size={13} />
View Document
</Button>
) : (
<div
className="flex items-center gap-2"
style={{
padding: "10px 12px",
borderRadius: 8,
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{docStatus === "NN"
? "No completion document was required for this work order."
: "No completion document was generated."}
</span>
</div>
)
) : (
<div className="space-y-2">
{Boolean(onUploadCompletionPdf) && (
<>
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
disabled={!row.rowVersion}
loading={completionPending}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!completionPending ? <Upload size={13} /> : undefined}
>
Upload signed PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
handlePdfSelected(event.target.files);
event.target.value = "";
}}
/>
{!row.rowVersion && (
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Waiting for work order version before upload is available.
</Text>
)}
</>
)}
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
{docStatus === "Yes" ? (
<>
<CheckCircle2 size={13} />
View / Edit Document
</>
) : docStatus === "NN" ? (
<>
<Ban size={13} />
Document Not Required — Review
</>
) : (
<>
<FileText size={13} />
Open completion document
</>
)}
</Button>
</div>
)}
{Boolean(completion && completion.dispatchSignoffs.length > 0) && (
<div className="space-y-1 pt-2">
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-text-muted)",
}}
>
Dispatch sign-offs
</span>
{completion.dispatchSignoffs.map((signoff, index) => (
<div
key={`${signoff.dispatchId ?? "d"}-${index}`}
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{signoff.signoffType || "Sign-off"}
{signoff.name ? ` — ${signoff.name}` : ""}
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,159 @@
import { useRef } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
const ACCEPT = "image/*,video/*,.pdf,.doc,.docx";
type SlideOverExtrasTabProps = {
uploads: MediaFile[];
isReadOnly: boolean;
pending?: boolean;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
};
export function SlideOverExtrasTab({
uploads,
isReadOnly,
pending = false,
onUpload,
onRemove,
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length > 0) {
onUpload(accepted);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Extra Documents
</span>
</div>
{!isReadOnly && (
<>
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 100,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
aria-busy={pending || undefined}
>
{pending ? "Uploading…" : "Upload documents, photos or videos"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
multiple
hidden
disabled={pending}
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</>
)}
{uploads.length === 0 ? (
<Text as="p" style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
No extra documents yet.
</Text>
) : (
<div className="space-y-1">
{uploads.map((file) => {
const ItemIcon = FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
return (
<div key={file.id} className="flex items-center gap-2 py-1">
<ItemIcon
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
{showDelete && (
<button
type="button"
title="Remove"
disabled={pending}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,56 @@
import { Button, Menu, MenuItem } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderAssigneeMenuProps = {
assignedTo: string;
users: WorkOrderUser[];
disabled?: boolean;
onAssign: (userId: string | number, userName: string) => void;
};
export function WorkOrderAssigneeMenu({
assignedTo,
users,
disabled = false,
onAssign,
}: WorkOrderAssigneeMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<Button
size="small"
disabled={disabled}
aria-busy={disabled || undefined}
onClick={(event) => setAnchor(event.currentTarget)}
>
{disabled ? "Updating…" : assignedTo || "Unassigned"}
</Button>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
<MenuItem
disabled={disabled}
onClick={() => {
close();
onAssign("", "");
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={assignedTo === user.name}
disabled={disabled}
onClick={() => {
close();
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -0,0 +1,264 @@
import { useMemo, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Paper,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import { Button } from "@/components/ui/button";
import { AuditTimeline } from "./audit-timeline";
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
type ActivityTab = "customer" | "internal" | "audit";
type WorkOrderDetailsSectionProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
isAssignPending?: boolean;
onDispatchClick: (dispatchId: string | number) => void;
onCommentSubmit: (text: string, commentType: "customer" | "internal") => void;
isCommentPending: boolean;
};
export function WorkOrderDetailsSection({
wo,
users,
onAssign,
isAssignPending = false,
onDispatchClick,
onCommentSubmit,
isCommentPending,
}: WorkOrderDetailsSectionProps) {
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const customerComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo.comments],
);
const internalComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo.comments],
);
const auditLog = wo.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal");
setCommentText("");
};
return (
<>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<DetailFieldGrid>
<DetailField label="Internal WO" value={wo.internalWONumber} />
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
<DetailField label="Priority" value={wo.priority || "—"} />
<DetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Assigned To
</Typography>
<WorkOrderAssigneeMenu
assignedTo={wo.assignedTo}
users={users}
disabled={isAssignPending}
onAssign={onAssign}
/>
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
</DetailFieldGrid>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<DetailFieldGrid>
<DetailField label="Problem" value={wo.problem || "—"} />
<DetailField label="Trade" value={wo.trade || "—"} />
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
</DetailFieldGrid>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailField
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<DetailField label="Due Date" value={wo.dueDate || "—"} />
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
<DetailField label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<DetailField
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<DetailField label="Customer PO" value={wo.po || "—"} />
<DetailField label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const color = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => onDispatchClick(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography color="text.secondary">No dispatches yet.</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(customerComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "internal" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(internalComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={isCommentPending}
/>
<Button
type="submit"
className="self-end"
disabled={!commentText.trim()}
loading={isCommentPending}
loadingText="Sending…"
>
Send
</Button>
</Box>
)}
</Paper>
</>
);
}

View file

@ -0,0 +1,380 @@
import { useEffect, useMemo, useState } from "react";
import { Box } from "@mui/material";
import { toast } from "react-toastify";
import { SideSheet } from "@/components/ui/side-sheet";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useDeleteWorkOrderMedia,
useUpdateWorkOrderComment,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useAuthContext } from "@/providers/auth-context";
type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "extras", label: "Extra Docs" },
];
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
onCancelWO: (row: WorkOrderTableRow) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
};
export function WorkOrderSlideOver({
row,
editMode,
users,
sites,
vendors,
pmTypes,
allOrders,
onSave,
onOpenCompletionDoc,
onCancelWO,
onDuplicateFound,
onClose,
saving,
}: WorkOrderSlideOverProps) {
const { user } = useAuthContext();
const workOrderId = row?.id;
const {
data: detail,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
useEffect(() => {
if (!row) return;
setDraft(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
}, [row, editMode]);
useEffect(() => {
if (!infoSource) return;
setDraft((prev) => {
if (editing && prev && isSlideOverDraftDirty(prev, infoSource)) return prev;
return infoSource;
});
}, [infoSource, editing]);
const activeDraft = draft ?? infoSource;
const isDirty = useMemo(() => {
if (!infoSource || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, infoSource);
}, [activeDraft, editing, infoSource]);
const completionMedia = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter(
(item) =>
item.category === "Before" ||
item.category === "After" ||
item.category === "Completion",
),
),
[detailForRow?.media],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
),
[detailForRow?.media],
);
const mediaPending = uploadMediaMutation.isPending || deleteMediaMutation.isPending;
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
};
const commit = () => {
if (!row || !activeDraft || !infoSource) return;
const patch = buildSlideOverPatch(activeDraft, infoSource);
if (Object.keys(patch).length > 0) {
onSave(row.id, patch);
}
setEditing(false);
setShowUnsaved(false);
};
const discard = () => {
if (!infoSource) return;
setDraft(infoSource);
setEditing(false);
setShowUnsaved(false);
};
const requestClose = () => {
if (closeDisabled) return;
if (editing && isDirty) {
setShowUnsaved(true);
return;
}
onClose();
};
const handleCancelEdit = () => {
if (isDirty) {
setShowUnsaved(true);
return;
}
if (infoSource) setDraft(infoSource);
setEditing(false);
};
const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage =
error instanceof Error ? error.message : "Failed to load work order details.";
return (
<SideSheet
open={Boolean(row)}
onClose={requestClose}
closeDisabled={closeDisabled}
widthClass="w-[480px] max-w-[480px]"
aria-label="Work order details"
>
{row != null && infoSource != null && activeDraft != null && (
<Box className="flex h-full flex-col overflow-hidden">
<SlideOverHeader
row={infoSource}
isCancelled={isCancelled}
isFetching={isFetching && !isLoading}
closeDisabled={closeDisabled}
onClose={requestClose}
/>
<div
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
style={{ borderBottom: "1px solid var(--color-border)" }}
role="tablist"
aria-label="Work order sections"
>
{SLIDE_OVER_TABS.map((item) => {
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
disabled={Boolean(saving)}
onClick={() => setTab(item.id)}
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
style={{
borderBottomColor: active ? "var(--color-primary)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
fontWeight: active ? 500 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={editing && !saving}
isCancelled={isCancelled}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={handleDraftChange}
onDuplicateFound={onDuplicateFound}
/>
)}
{tab === "comments" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="comments" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow != null && (
<SlideOverCommentsTab
comments={detailForRow.comments}
isReadOnly={isCancelled}
isPending={commentMutation.isPending}
currentUserId={user?.id}
isUpdatePending={updateCommentMutation.isPending}
onSubmit={(text) => commentMutation.mutate({ text })}
onUpdateComment={(commentId, text, options) =>
updateCommentMutation.mutate({ commentId, text }, options)
}
/>
)}
</>
)}
{tab === "audit" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="audit" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow != null && (
<SlideOverAuditTab entries={detailForRow.audit} users={users} />
)}
</>
)}
{tab === "completion" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="completion" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{Boolean(detailForRow || !isLoading) && (
<SlideOverCompletionTab
row={infoSource}
isReadOnly={isDocumentsReadOnly}
completion={detailForRow?.completion}
media={completionMedia}
mediaPending={mediaPending}
completionPending={completionPending}
onUploadMedia={(files, category) => {
for (const file of files) {
uploadMediaMutation.mutate({ file, category });
}
}}
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
onUploadCompletionPdf={(file) => {
const version = infoSource.rowVersion;
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate({
file,
workOrderVersion: version,
});
}}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
/>
)}
</>
)}
{tab === "extras" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="extras" />}
{Boolean(error) && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{Boolean(detailForRow || !isLoading) && (
<SlideOverExtrasTab
uploads={extrasUploads}
isReadOnly={isDocumentsReadOnly}
pending={mediaPending}
onUpload={(files) => {
for (const file of files) {
uploadMediaMutation.mutate({ file, category: "Extra" });
}
}}
onRemove={(id) => deleteMediaMutation.mutate(id)}
/>
)}
</>
)}
</Box>
<SlideOverFooter
isCancelled={isCancelled}
editing={editing}
isDirty={isDirty}
showUnsaved={showUnsaved}
saving={saving}
onCancelWO={() => onCancelWO(infoSource)}
onCancelEdit={handleCancelEdit}
onClose={requestClose}
onEdit={() => {
setDraft(infoSource);
setEditing(true);
setTab("info");
}}
onSave={commit}
onDiscard={() => {
discard();
onClose();
}}
onKeepEditing={() => setShowUnsaved(false)}
/>
</Box>
)}
</SideSheet>
);
}

View file

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

View file

@ -1,32 +1,53 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { type MouseEvent, useMemo } from "react";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
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 { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
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";
import { MediaUploader } from "./media-uploader";
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) {
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const uploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detail?.media ?? []).filter(
(item) =>
item.category === "Before" ||
item.category === "After" ||
item.category === "Completion",
),
),
[detail?.media],
);
const pending = uploadMutation.isPending || deleteMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -49,7 +70,27 @@ export function MediaUploadModal({
</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={(files, category) => {
for (const file of files) {
uploadMutation.mutate({ file, category });
}
}}
onRemove={(id) => deleteMutation.mutate(id)}
readOnly={isReadOnly}
pending={pending}
/>
)}
</div>
<div className="mt-2 flex justify-end">
@ -57,6 +98,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Close

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import {
File as FileIcon,
FileText,
@ -9,7 +9,14 @@ import {
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
@ -20,58 +27,140 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
other: Paperclip,
};
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
type MediaUploaderProps = {
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
/** When provided, uploads go to the API with the selected Before/After category. */
onUpload?: (files: File[], category: UploadableMediaCategory) => void;
onRemove?: (id: 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,
onChange,
readOnly = false,
pending = false,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [category, setCategory] = useState<UploadableMediaCategory>("Before");
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 (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length === 0) return;
if (onUpload) {
onUpload(accepted, category);
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)) return;
if (onRemove) {
onRemove(id);
return;
}
onChange?.(uploads.filter((u) => u.id !== id));
};
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)}
/>
{!readOnly && (
<>
{Boolean(onUpload) && (
<div className="mb-2 flex items-center gap-2">
<label
className="text-[11px]"
style={{ color: "var(--color-text-muted)" }}
htmlFor="media-upload-category"
>
Category
</label>
<select
id="media-upload-category"
value={category}
disabled={pending}
onChange={(event) => setCategory(event.target.value as UploadableMediaCategory)}
className="rounded border px-2 py-1 text-[11px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
{PHOTO_CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
)}
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
disabled={pending}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
</>
)}
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
const showDelete = !readOnly && canDeleteWorkOrderMedia(u);
return (
<div
key={u.id}
@ -106,24 +195,27 @@ export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
<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>
{showDelete && (
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
<div className="px-1 py-1">
<div