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

This commit is contained in:
Arthur Bassi 2026-07-17 10:47:24 -03:00
parent 7c7ed57577
commit 46b8184d62
17 changed files with 2285 additions and 290 deletions

View file

@ -1,6 +1,16 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
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";
@ -13,7 +23,8 @@ import type {
} 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 { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
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 {
@ -22,6 +33,11 @@ import {
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";
@ -54,13 +70,20 @@ export function CompDocDialog({
onDateCompletedChange,
}: CompDocDialogProps) {
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 { 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 site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
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");
@ -70,16 +93,24 @@ export function CompDocDialog({
useEffect(() => {
if (!open) return;
setNotes(data?.notes ?? "");
setDocState(docStateOf(row.docStatus));
setDocState(docStateOf(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(row.scheduledOn || "");
}, [open, row.id, row.docStatus, row.scheduledOn, data?.notes, data?.generatedAt]);
setEditDateCompleted(info.scheduledOn || "");
}, [
open,
info.id,
info.docStatus,
info.scheduledOn,
completion?.docStatus,
data?.notes,
data?.generatedAt,
]);
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
const docFields: DocFields = {
street: site.street,
@ -107,7 +138,7 @@ export function CompDocDialog({
function handleSaveDraft() {
persist({}, "No");
if (editDateCompleted !== row.scheduledOn) {
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
toast(
@ -132,7 +163,6 @@ export function CompDocDialog({
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
bodyClassName: "!p-0",
},
);
}
@ -142,20 +172,58 @@ export function CompDocDialog({
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
if (editDateCompleted !== row.scheduledOn) {
if (editDateCompleted !== info.scheduledOn) {
onDateCompletedChange?.(editDateCompleted);
}
onStatusChange?.("Completed");
}
function handleDownloadPdf() {
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
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={() => onOpenChange(false)}
onClose={handleClose}
disableEscapeKeyDown={isUploading}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -181,7 +249,7 @@ export function CompDocDialog({
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
<DialogTitle className="sr-only">Completion Document — WO #{info.woNumber}</DialogTitle>
<p className="sr-only">
Generate, save or mark not-required the completion document for this work order.
</p>
@ -223,10 +291,10 @@ export function CompDocDialog({
className="font-mono"
style={{ color: "var(--color-primary)", fontWeight: 600 }}
>
#{row.woNumber}
#{info.woNumber}
</span>
{" · "}
{serviceTitle(row)}
{serviceTitle(info)}
</div>
</div>
</div>
@ -255,9 +323,12 @@ export function CompDocDialog({
</span>
<button
type="button"
onClick={() => onOpenChange(false)}
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)]"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,
@ -270,9 +341,44 @@ export function CompDocDialog({
</div>
</div>
{(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)",
}}
>
{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>
)}
{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={row}
row={info}
site={site}
notes={notes}
generatedAt={generatedAt}
@ -280,7 +386,7 @@ export function CompDocDialog({
isNotRequired={isNotRequired}
/>
<CompDocSidebar
row={row}
row={info}
site={site}
docState={docState}
generatedAt={generatedAt}
@ -302,7 +408,17 @@ export function CompDocDialog({
background: "var(--color-bg-surface)",
}}
>
{docState === "draft" && (
<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"
@ -324,6 +440,19 @@ export function CompDocDialog({
>
<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]"
@ -336,27 +465,44 @@ export function CompDocDialog({
</div>
</>
)}
{docState === "generated" && (
<>
<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={FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={handleDownloadPdf}
{(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)" }}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
)}
<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
@ -367,15 +513,17 @@ export function CompDocDialog({
<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={FOOTER_BTN_SX}
onClick={handleUndo}
>
<Undo2 size={14} /> Undo
</Button>
{!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

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>
{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>
) : null}
</Box>
))}
</Box>
);
}

View file

@ -0,0 +1,157 @@
import { Settings } from "lucide-react";
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>
<p
className="mb-3 text-[10px] font-medium uppercase"
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
>
Audit Log
</p>
<div className="space-y-2.5">
{entries.length === 0 ? (
<p className="py-4 text-center text-[12px]" style={{ color: "var(--muted-foreground)" }}>
No changes recorded
</p>
) : (
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 ? (
<p className="text-[12px] leading-snug" style={{ color: "#374151" }}>
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
<span style={{ color: "#6B7280" }}> — </span>
{action}
</p>
) : (
<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>
</p>
)}
<p
className="mt-0.5 text-[10px]"
style={{
fontFamily: "var(--font-mono)",
color: "#9CA3AF",
}}
>
{formatAuditTime(entry.createdAt, isSys)}
</p>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -0,0 +1,142 @@
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
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)" }} />
<p className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</p>
</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,104 @@
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 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">
<h2
className="text-[15px] font-semibold leading-none"
style={{ color: "var(--color-text-primary)" }}
>
#{row.woNumber || "—"}
</h2>
{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>
) : null}
{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>
) : null}
{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}
{row.type ? <WoTableTypeLabel type={row.type as WOType} /> : null}
</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,260 @@
import { useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
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>
) : (
<p
className="mt-0.5 text-[12px] leading-snug"
style={{ color: "var(--color-text-secondary)" }}
>
{comment.commenttext}
</p>
)}
</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 ? (
<p className="py-6 text-center text-[12px]" style={{ color: "var(--muted-foreground)" }}>
No comments yet
</p>
) : (
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,233 @@
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 { 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>
{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>
)}
{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">
{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 && (
<p style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Waiting for work order version before upload is available.
</p>
)}
</>
)}
<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>
)}
{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,156 @@
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 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 ? (
<p style={{ fontSize: 12, color: "var(--color-text-muted)" }}>No extra documents yet.</p>
) : (
<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 ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: getDispatcherAvatarColor(dispatcher) }}
/>
) : null}
{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>
{source.tech && source.company ? (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
) : null}
</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

@ -5,19 +5,31 @@ 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, onAssign }: WorkOrderAssigneeMenuProps) {
export function WorkOrderAssigneeMenu({
assignedTo,
users,
disabled = false,
onAssign,
}: WorkOrderAssigneeMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<Button size="small" onClick={(event) => setAnchor(event.currentTarget)}>
{assignedTo || "Unassigned"}
<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("", "");
@ -29,6 +41,7 @@ export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrder
<MenuItem
key={String(user.id)}
selected={assignedTo === user.name}
disabled={disabled}
onClick={() => {
close();
onAssign(user.id, user.name);

View file

@ -4,7 +4,6 @@ import {
AccordionDetails,
AccordionSummary,
Box,
Button,
Paper,
Tab,
Tabs,
@ -21,54 +20,19 @@ 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 {
WorkOrderAuditEntry,
WorkOrderDetail,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
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";
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
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">No changes recorded.</Typography>;
}
return (
<Box className="max-h-72 space-y-4 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">
<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">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Box>
);
}
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;
@ -78,6 +42,7 @@ export function WorkOrderDetailsSection({
wo,
users,
onAssign,
isAssignPending = false,
onDispatchClick,
onCommentSubmit,
isCommentPending,
@ -119,7 +84,12 @@ export function WorkOrderDetailsSection({
<Typography variant="caption" color="text.secondary" display="block">
Assigned To
</Typography>
<WorkOrderAssigneeMenu assignedTo={wo.assignedTo} users={users} onAssign={onAssign} />
<WorkOrderAssigneeMenu
assignedTo={wo.assignedTo}
users={users}
disabled={isAssignPending}
onAssign={onAssign}
/>
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
@ -279,11 +249,12 @@ export function WorkOrderDetailsSection({
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={isCommentPending || !commentText.trim()}
disabled={!commentText.trim()}
loading={isCommentPending}
loadingText="Sending…"
>
{isCommentPending ? "Sending..." : "Send"}
Send
</Button>
</Box>
)}

View file

@ -1,142 +1,378 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, CircularProgress, IconButton, Tab, Tabs, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useEffect, useMemo, useState } from "react";
import { Box } from "@mui/material";
import { toast } from "react-toastify";
import { SideSheet } from "@/components/ui/side-sheet";
import { WorkOrderDetailsSection } from "@/app/(protected)/workorders/_components/detail/work-order-detail-sections";
import { WorkOrderStatusMenu } from "@/app/(protected)/workorders/_components/detail/work-order-status-menu";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
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,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useDeleteWorkOrderMedia,
useUpdateWorkOrderComment,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
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, onClose }: WorkOrderSlideOverProps) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
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: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
const statusMutation = useChangeWorkOrderStatus(workOrderId ?? "");
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId ?? "");
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={onClose}
widthClass="w-full max-w-xl"
onClose={requestClose}
closeDisabled={closeDisabled}
widthClass="w-[480px] max-w-[480px]"
aria-label="Work order details"
>
<Box className="flex h-full flex-col">
<Box className="flex items-start justify-between border-b border-border px-4 py-3">
<Box>
<Typography variant="overline" color="text.secondary">
Work Order
</Typography>
<Typography variant="h6" component="h2">
{row?.woNumber || row?.title || "—"}
</Typography>
{wo && (
<Box className="mt-1">
<WorkOrderStatusMenu
status={wo.status}
onStatusChange={(status) => statusMutation.mutate(status)}
/>
</Box>
)}
</Box>
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
{row && infoSource && activeDraft && (
<Box className="flex h-full flex-col overflow-hidden">
<SlideOverHeader
row={infoSource}
isCancelled={isCancelled}
isFetching={isFetching && !isLoading}
closeDisabled={closeDisabled}
onClose={requestClose}
/>
<Tabs value={tab} onChange={(_, v) => setTab(v)} className="border-b border-border px-2">
<Tab label="Info" />
<Tab label="Activity" />
</Tabs>
<Box className="flex-1 overflow-y-auto p-4">
{isLoading && (
<Box className="flex justify-center py-12">
<CircularProgress size={28} />
</Box>
)}
{error && (
<Typography color="error" variant="body2">
{error instanceof Error ? error.message : "Failed to load work order."}
</Typography>
)}
{wo && tab === 0 && (
<Box className="space-y-3">
<Typography variant="body2">
<strong>Title:</strong> {wo.title}
</Typography>
<Typography variant="body2">
<strong>Location:</strong> {wo.location}
</Typography>
<Typography variant="body2">
<strong>Assigned:</strong> {wo.assignedTo || "Unassigned"}
</Typography>
<Typography variant="body2">
<strong>Due:</strong> {wo.dueDate || "—"}
</Typography>
<Typography variant="body2">
<strong>Scheduled:</strong> {wo.scheduledDate || "—"}
</Typography>
<Typography variant="body2">
<strong>Problem:</strong> {wo.problem || "—"}
</Typography>
{wo.description && (
<Typography variant="body2" className="whitespace-pre-wrap">
{wo.description}
</Typography>
)}
<Button
variant="outlined"
size="small"
onClick={() => {
onClose();
navigate(`/workorders/${wo.id}`);
}}
>
Open full page
</Button>
{editMode && (
<Button
variant="contained"
size="small"
onClick={() => {
onClose();
navigate(`/workorders/edit/${wo.id}`);
<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,
}}
>
Edit work order
</Button>
)}
</Box>
)}
{wo && tab === 1 && (
<WorkOrderDetailsSection
wo={wo}
users={users}
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
onDispatchClick={() => {}}
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
isCommentPending={commentMutation.isPending}
/>
)}
{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" />}
{error && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow && (
<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" />}
{error && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{detailForRow && <SlideOverAuditTab entries={detailForRow.audit} users={users} />}
</>
)}
{tab === "completion" && (
<>
{showDetailSkeleton && <SlideOverTabSkeleton variant="completion" />}
{error && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{(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" />}
{error && !detailForRow && (
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
)}
{(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>
</Box>
)}
</SideSheet>
);
}

View file

@ -6,15 +6,26 @@ import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = {
status: string;
disabled?: boolean;
onStatusChange: (status: string) => void;
};
export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusMenuProps) {
export function WorkOrderStatusMenu({
status,
disabled = false,
onStatusChange,
}: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<MenuAnchorButton onAnchor={setAnchor} open={open} aria-label="Change status">
<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}>
@ -22,6 +33,7 @@ export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusM
<MenuItem
key={option}
selected={option === status}
disabled={disabled}
onClick={() => {
close();
if (option !== status) {

View file

@ -1,31 +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 type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { LoadingRegion } from "@/components/ui/loading-region";
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}
disableEscapeKeyDown={pending}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -46,7 +68,27 @@ export function MediaUploadModal({
<p className="sr-only">Upload photos and videos for this work order.</p>
<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">
@ -54,6 +96,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 && (
<>
{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