diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx new file mode 100644 index 00000000..8ac61c29 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -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(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("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, 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( +
+ Draft saved +
, + { + 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 ( + ) => e.stopPropagation(), + }, + }} + > + + Completion Document — WO #{info.woNumber} + + Generate, save or mark not-required the completion document for this work order. + + +
+
+
+ +
+
+ Completion Document +
+
+ WO{" "} + + #{info.woNumber} + + {" · "} + {serviceTitle(info)} +
+
+
+
+ + + {cfg.pillLabel} + + +
+
+ + {Boolean(templateUrl || signOffAttachment) && ( +
+ {Boolean(templateUrl) && ( + + + Download blank template + + )} + {Boolean(signOffAttachment) && ( + + + Open uploaded PDF + + )} +
+ )} + +
+ + +
+ +
+ { + handleUploadPdf(event.target.files); + event.target.value = ""; + }} + /> + {docState === "draft" && !isDocumentsReadOnly && ( + <> + +
+ + + +
+ + )} + {(docState === "generated" || Boolean(signOffAttachment)) && + docState !== "not-required" && ( + <> + + {" "} + {signOffAttachment ? "Document on file" : `Generated ${generatedAt}`} + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + )} + {docState === "not-required" && ( + <> + + Marked{" "} + not required for this work order + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + )} +
+
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx new file mode 100644 index 00000000..ee56c393 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/audit-timeline.tsx @@ -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 {emptyMessage}; + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.action ? ( + entry.action + ) : ( + <> + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + )} + + {Boolean(entry.action && entry.fieldName) && ( + + {entry.fieldName} + {entry.oldValue || entry.newValue + ? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}` + : ""} + + )} + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx new file mode 100644 index 00000000..57c72270 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx @@ -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 ( +
+ + Audit Log + +
+ {entries.length === 0 ? ( + + No changes recorded + + ) : ( + 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 ( +
+ {isSys ? ( +
+ +
+ ) : ( +
+ {initials} +
+ )} +
+ {isSys ? ( + + System + — + {action} + + ) : ( + + + {name} + + · + {action} + + )} + + {formatAuditTime(entry.createdAt, isSys)} + +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx new file mode 100644 index 00000000..82b03b26 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx @@ -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 ( +
+ + + You have unsaved changes. + +
+ + + +
+
+ ); + } + + if (isCancelled) { + return ( +
+ + + This work order has been cancelled and is read-only. + +
+ ); + } + + return ( +
+ + +
+ + + {editing ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx new file mode 100644 index 00000000..0302625e --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx @@ -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 ( +
+
+ + #{row.woNumber || "—"} + + + {isCancelled && ( + + Cancelled + + )} + + {Boolean(row.site) && ( + + {row.site} + + )} + + {row.isPastDue ? ( + + + Past Due + + ) : row.status ? ( + + ) : null} + + {Boolean(row.type) && } +
+ +
+ {isFetching && } + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx new file mode 100644 index 00000000..38942db6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/slide-over-info-row.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +type SlideOverInfoRowProps = { + label: string; + children: ReactNode; +}; + +export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) { + return ( +
+ + {label} + +
+ {children} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx new file mode 100644 index 00000000..1b8200e0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx @@ -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 ; +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx new file mode 100644 index 00000000..22c5e6a1 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx @@ -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 ( +
+ + {initials} + +
+
+
+ + {author} + + + {formatCommentTime(comment.createdDate)} + +
+ {canEdit && !isEditing && ( + + )} +
+ + {isEditing ? ( +
+