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 index 1a571b91..291ba462 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -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(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("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 ( onOpenChange(false)} + onClose={handleClose} + disableEscapeKeyDown={isUploading} maxWidth={false} slotProps={{ ...dialogBlurBackdropSlotProps, @@ -181,7 +249,7 @@ export function CompDocDialog({ overflow: "hidden", }} > - Completion Document — WO #{row.woNumber} + Completion Document — WO #{info.woNumber}

Generate, save or mark not-required the completion document for this work order.

@@ -223,10 +291,10 @@ export function CompDocDialog({ className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }} > - #{row.woNumber} + #{info.woNumber} {" · "} - {serviceTitle(row)} + {serviceTitle(info)} @@ -255,9 +323,12 @@ export function CompDocDialog({ + - - - )} + {" "} + {signOffAttachment ? "Document on file" : `Generated ${generatedAt}`} + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + )} {docState === "not-required" && ( <> not required for this work order
- + {!isDocumentsReadOnly && ( + + )} + + +
+ + ); + } + + 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..02f8fdf0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx @@ -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 ( +
+
+

+ #{row.woNumber || "—"} +

+ + {isCancelled ? ( + + Cancelled + + ) : null} + + {row.site ? ( + + {row.site} + + ) : null} + + {row.isPastDue ? ( + + + Past Due + + ) : row.status ? ( + + ) : null} + + {row.type ? : null} +
+ +
+ {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..7a9b49c3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab.tsx @@ -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 ( +
+ + {initials} + +
+
+
+ + {author} + + + {formatCommentTime(comment.createdDate)} + +
+ {canEdit && !isEditing && ( + + )} +
+ + {isEditing ? ( +
+