From 68b85940feddaac42ec2fe89f72e2b6045fb145c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 28 Jul 2026 10:33:09 -0300 Subject: [PATCH] refactor(work-orders): split slide-over and CompDoc for governance Extract focused hooks and sibling components so CompDoc, slide-over, media uploader, and list page meet maintainability ratchets. --- .../comp-doc-dialog-footer-draft-actions.tsx | 70 +++ ...mp-doc-dialog-footer-generated-actions.tsx | 62 ++ ...doc-dialog-footer-not-required-actions.tsx | 53 ++ .../comp-doc/comp-doc-dialog-footer.tsx | 89 +++ .../comp-doc/comp-doc-dialog-header.tsx | 96 ++++ .../comp-doc-dialog-resource-links.tsx | 48 ++ .../_components/comp-doc/comp-doc-dialog.tsx | 532 ++---------------- .../comp-doc/comp-doc-edit-row.tsx | 51 ++ .../comp-doc/comp-doc-footer-button-sx.ts | 8 + .../comp-doc-preview-company-header.tsx | 36 ++ .../comp-doc/comp-doc-preview-document.tsx | 58 ++ .../comp-doc-preview-not-required-stamp.tsx | 25 + .../comp-doc-preview-procedure-notes.tsx | 68 +++ .../comp-doc-preview-service-info.tsx | 89 +++ .../comp-doc-preview-status-badge.tsx | 32 ++ .../comp-doc-preview-vendor-safety.tsx | 35 ++ .../_components/comp-doc/comp-doc-preview.tsx | 253 +-------- .../_components/comp-doc/comp-doc-sidebar.tsx | 51 +- .../comp-doc/use-comp-doc-dialog.tsx | 196 +++++++ .../comp-doc/use-comp-doc-pdf-actions.ts | 81 +++ .../tabs/slide-over-completion-actions.tsx | 92 +++ .../tabs/slide-over-completion-media.tsx | 40 ++ .../slide-over-completion-readonly-status.tsx | 45 ++ .../tabs/slide-over-completion-signoffs.tsx | 35 ++ .../detail/tabs/slide-over-completion-tab.tsx | 153 +---- .../detail/tabs/slide-over-doc-badge.tsx | 44 ++ .../slide-over-info-tab-assigned-field.tsx | 28 + .../tabs/slide-over-info-tab-display-view.tsx | 92 +++ .../tabs/slide-over-info-tab-edit-view.tsx | 160 ++++++ .../tabs/slide-over-info-tab-status-field.tsx | 26 + .../tabs/slide-over-info-tab-vendor-field.tsx | 45 ++ .../detail/tabs/slide-over-info-tab.tsx | 311 +--------- .../detail/use-slide-over-edit-state.ts | 145 +++++ .../detail/use-slide-over-media-state.ts | 56 ++ .../detail/use-work-order-slide-over.ts | 122 ++++ .../detail/work-order-activity-section.tsx | 101 ++++ .../work-order-description-accordion.tsx | 20 + .../detail/work-order-detail-sections.tsx | 258 +-------- .../detail/work-order-details-card.tsx | 45 ++ .../work-order-dispatches-accordion.tsx | 85 +++ .../detail/work-order-simple-accordions.tsx | 61 ++ .../detail/work-order-slide-over-tab-nav.tsx | 52 ++ .../work-order-slide-over-tab-panel.tsx | 33 ++ .../detail/work-order-slide-over-tabs.tsx | 154 +++++ .../detail/work-order-slide-over.tsx | 380 ++----------- .../list/work-order-cancel-dialog.tsx | 50 ++ .../media/media-uploader-dropzone.tsx | 94 ++++ .../media/media-uploader-grid-item.tsx | 96 ++++ .../_components/media/media-uploader-grid.tsx | 32 ++ .../_components/media/media-uploader.tsx | 179 +----- .../_hooks/use-work-orders-list-page.ts | 120 ++++ src/app/(protected)/workorders/index.tsx | 218 ++----- .../use-cases/use-work-order-mutations.ts | 2 +- 53 files changed, 3222 insertions(+), 2085 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts create mode 100644 src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts create mode 100644 src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx create mode 100644 src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx new file mode 100644 index 00000000..86749598 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx @@ -0,0 +1,70 @@ +import type { RefObject } from "react"; +import { Ban, Save, Sparkles, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterDraftActionsProps = { + rowVersion?: string | number | null; + isUploading: boolean; + pdfInputRef: RefObject; + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraftActions({ + rowVersion, + isUploading, + pdfInputRef, + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftActionsProps) { + return ( + <> + +
+ + + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx new file mode 100644 index 00000000..5c006a97 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated-actions.tsx @@ -0,0 +1,62 @@ +import type { RefObject } from "react"; +import { CheckCircle2, Download, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterGeneratedActionsProps = { + generatedAt: string; + signOffAttachment?: string | null; + isDocumentsReadOnly: boolean; + rowVersion?: string | number | null; + isUploading: boolean; + pdfInputRef: RefObject; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGeneratedActions({ + generatedAt, + signOffAttachment, + isDocumentsReadOnly, + rowVersion, + isUploading, + pdfInputRef, + onDownloadPdf, +}: CompDocDialogFooterGeneratedActionsProps) { + return ( + <> + + {" "} + {signOffAttachment ? "Document on file" : `Generated ${generatedAt}`} + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx new file mode 100644 index 00000000..79d16bca --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required-actions.tsx @@ -0,0 +1,53 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx"; + +type CompDocDialogFooterNotRequiredActionsProps = { + isDocumentsReadOnly: boolean; + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequiredActions({ + isDocumentsReadOnly, + onUndo, +}: CompDocDialogFooterNotRequiredActionsProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
+ {!isDocumentsReadOnly && ( + + )} + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx new file mode 100644 index 00000000..30c5ef8a --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx @@ -0,0 +1,89 @@ +import type { RefObject } from "react"; +import type { DocState } from "@/domain/work-orders/types/completion-doc"; +import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions"; +import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions"; +import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions"; + +type CompDocDialogFooterProps = { + docState: DocState; + isDocumentsReadOnly: boolean; + rowVersion?: string | number | null; + isUploading: boolean; + signOffAttachment?: string | null; + generatedAt: string; + pdfInputRef: RefObject; + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; + onUndo: () => void; + onDownloadPdf: () => void; + onUploadPdf: (files: FileList | null) => void; +}; + +export function CompDocDialogFooter({ + docState, + isDocumentsReadOnly, + rowVersion, + isUploading, + signOffAttachment, + generatedAt, + pdfInputRef, + onMarkNotRequired, + onSaveDraft, + onGenerate, + onUndo, + onDownloadPdf, + onUploadPdf, +}: CompDocDialogFooterProps) { + const showGenerated = + (docState === "generated" || Boolean(signOffAttachment)) && docState !== "not-required"; + + return ( +
+ { + onUploadPdf(event.target.files); + event.target.value = ""; + }} + /> + {docState === "draft" && !isDocumentsReadOnly && ( + + )} + {showGenerated && ( + + )} + {docState === "not-required" && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx new file mode 100644 index 00000000..08a9cb20 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -0,0 +1,96 @@ +import { FileText, X } from "lucide-react"; +import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; + +type CompDocDialogHeaderProps = { + info: WorkOrderTableRow; + cfg: CompletionDocStateConfig; + isUploading: boolean; + onClose: () => void; +}; + +export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { + return ( +
+
+ +
+
+ Completion Document +
+
+ WO{" "} + + #{info.woNumber} + + {" · "} + {serviceTitle(info)} +
+
+
+
+ + + {cfg.pillLabel} + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx new file mode 100644 index 00000000..bb772f0b --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-resource-links.tsx @@ -0,0 +1,48 @@ +import { Download, FileText } from "lucide-react"; + +type CompDocDialogResourceLinksProps = { + templateUrl?: string | null; + signOffAttachment?: string | null; +}; + +export function CompDocDialogResourceLinks({ + templateUrl, + signOffAttachment, +}: CompDocDialogResourceLinksProps) { + if (!templateUrl && !signOffAttachment) return null; + + return ( +
+ {Boolean(templateUrl) && ( + + + Download blank template + + )} + {Boolean(signOffAttachment) && ( + + + Open uploaded PDF + + )} +
+ ); +} 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 8ac61c29..e45070b9 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,55 +1,16 @@ -import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import 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 { CompDocData, CompDocSaveStatus } 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 { CompDocDialogFooter } from "./comp-doc-dialog-footer"; +import { CompDocDialogHeader } from "./comp-doc-dialog-header"; +import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; 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; +import { useCompDocDialog } from "./use-comp-doc-dialog"; type CompDocDialogProps = { row: WorkOrderTableRow; @@ -70,155 +31,15 @@ export function CompDocDialog({ 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 || ""); - }, [ + const dialog = useCompDocDialog({ + row, 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); + data, + onSave, + onStatusChange, + onDateCompletedChange, + }); + const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false); return ( - Completion Document — WO #{info.woNumber} + + Completion Document — WO #{dialog.info.woNumber} + Generate, save or mark not-required the completion document for this work order.
-
-
- -
-
- Completion Document -
-
- WO{" "} - - #{info.woNumber} - - {" · "} - {serviceTitle(info)} -
-
-
-
- - - {cfg.pillLabel} - - -
-
+ handleClose?.()} + /> - {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/comp-doc/comp-doc-edit-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx new file mode 100644 index 00000000..3823f39b --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-edit-row.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties } from "react"; + +const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; + +const COMP_DOC_INPUT_STYLE: CSSProperties = { + fontSize: 13, + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", +}; + +type CompDocEditRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + readOnly?: boolean; +}; + +export function CompDocEditRow({ + label, + value, + onChange, + type = "text", + readOnly = false, +}: CompDocEditRowProps) { + return ( +
+
+ {label} +
+ onChange(e.target.value)} + readOnly={readOnly} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(readOnly + ? { + opacity: 0.6, + cursor: "default", + pointerEvents: "none" as const, + } + : {}), + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-button-sx.ts @@ -0,0 +1,8 @@ +export const COMP_DOC_FOOTER_BTN_SX = { + minHeight: 28, + height: 28, + fontSize: 12, + px: 1.25, + py: 0.5, + gap: 0.75, +} as const; diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx new file mode 100644 index 00000000..0797a44d --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-company-header.tsx @@ -0,0 +1,36 @@ +import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react"; +import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html"; +import { CompDocCoLine } from "./comp-doc-co-line"; + +export function CompDocPreviewCompanyHeader() { + return ( + <> +
+
+ Seahaven Industries +
+
+ + + + + +
+
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx new file mode 100644 index 00000000..4c8c7872 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-document.tsx @@ -0,0 +1,58 @@ +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header"; +import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes"; +import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info"; +import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety"; + +type CompDocPreviewDocumentProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + notes: string; + generatedAt: string; + isGenerated: boolean; + isNotRequired: boolean; +}; + +export function CompDocPreviewDocument({ + row, + site, + notes, + generatedAt, + isGenerated, + isNotRequired, +}: CompDocPreviewDocumentProps) { + return ( +
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx new file mode 100644 index 00000000..c3ed0ec2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-not-required-stamp.tsx @@ -0,0 +1,25 @@ +import { Ban } from "lucide-react"; + +export function CompDocPreviewNotRequiredStamp() { + return ( +
+
+ NOT REQUIRED +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx new file mode 100644 index 00000000..e0c13e1a --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure-notes.tsx @@ -0,0 +1,68 @@ +import { Text } from "@/components/ui/text"; +import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures"; +import { CompDocProcedureList } from "./comp-doc-procedure-list"; + +type CompDocPreviewProcedureNotesProps = { + pm: string; + notes: string; +}; + +export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) { + const proc = procedureFor(pm); + + return ( + <> + + {proc.heading} + + + {proc.intro} + + + + {Boolean(notes) && ( + <> + + PM Comments + + + {notes} + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx new file mode 100644 index 00000000..8dd82d83 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-service-info.tsx @@ -0,0 +1,89 @@ +import { CheckCircle2 } from "lucide-react"; +import { Text } from "@/components/ui/text"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; + +type CompDocPreviewServiceInfoProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + isGenerated: boolean; + generatedAt: string; +}; + +export function CompDocPreviewServiceInfo({ + row, + site, + isGenerated, + generatedAt, +}: CompDocPreviewServiceInfoProps) { + return ( + <> + {isGenerated && ( +
+ Generated · {generatedAt} · v1 +
+ )} + + + {serviceTitle(row).toUpperCase()} + + + WO# {row.woNumber} + + +
+ + Site ID — {row.site} + + + Amazon.com Services LLC {row.site} + + + {site.street}, + + + {site.label} + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx new file mode 100644 index 00000000..5cf12ae6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-status-badge.tsx @@ -0,0 +1,32 @@ +import { CheckCircle2, FileText } from "lucide-react"; + +type CompDocPreviewStatusBadgeProps = { + isGenerated: boolean; +}; + +export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) { + return ( +
+ + {isGenerated ? ( + + ) : ( + + )} + {isGenerated ? "Generated document" : "Preview — not yet generated"} + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx new file mode 100644 index 00000000..807eca19 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-vendor-safety.tsx @@ -0,0 +1,35 @@ +import { Text } from "@/components/ui/text"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; + +type CompDocPreviewVendorSafetyProps = { + row: WorkOrderTableRow; +}; + +export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) { + return ( + <> + + Vendor / Technician:  + {row.tech || "—"} + {row.company ? ` · ${row.company}` : ""} + + +
    + {SAFETY_BULLETS.map((b, i) => ( +
  • + {b} +
  • + ))} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx index 318c7f63..df26f1a8 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx @@ -1,12 +1,8 @@ -import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react"; -import { Text } from "@/components/ui/text"; import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html"; -import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures"; -import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; -import { CompDocCoLine } from "./comp-doc-co-line"; -import { CompDocProcedureList } from "./comp-doc-procedure-list"; +import { CompDocPreviewDocument } from "./comp-doc-preview-document"; +import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp"; +import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge"; type CompDocPreviewProps = { row: WorkOrderTableRow; @@ -25,8 +21,6 @@ export function CompDocPreview({ isGenerated, isNotRequired, }: CompDocPreviewProps) { - const proc = procedureFor(row.pm); - return (
-
- - {isGenerated ? ( - - ) : ( - - )} - {isGenerated ? "Generated document" : "Preview — not yet generated"} - -
+
-
-
-
-
-
- Seahaven Industries -
-
- - - - - -
-
-
+ - {isGenerated && ( -
- Generated · {generatedAt} · v1 -
- )} - - - {serviceTitle(row).toUpperCase()} - - - WO# {row.woNumber} - - -
- - Site ID — {row.site} - - - Amazon.com Services LLC {row.site} - - - {site.street}, - - - {site.label} - -
- - - Vendor / Technician:  - {row.tech || "—"} - {row.company ? ` · ${row.company}` : ""} - - -
    - {SAFETY_BULLETS.map((b, i) => ( -
  • - {b} -
  • - ))} -
- - - {proc.heading} - - - {proc.intro} - - - - {Boolean(notes) && ( - <> - - PM Comments - - - {notes} - - - )} -
-
-
- - {isNotRequired && ( -
-
- NOT REQUIRED -
-
- )} + {isNotRequired && }
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx index 8e3b2c47..78b43895 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx @@ -1,4 +1,3 @@ -import type { CSSProperties } from "react"; import { Building2, MapPin, Users, Wrench } from "lucide-react"; import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -7,15 +6,7 @@ import { docStatusCardDescription, } from "@/domain/work-orders/utils/completion-doc-state"; import { CompDocDetailRow } from "./comp-doc-detail-row"; - -const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; - -const COMP_DOC_INPUT_STYLE: CSSProperties = { - fontSize: 13, - border: "1px solid var(--color-border)", - color: "var(--color-text-primary)", - background: "var(--color-bg-surface)", -}; +import { CompDocEditRow } from "./comp-doc-edit-row"; type CompDocSidebarProps = { row: WorkOrderTableRow; @@ -47,38 +38,6 @@ export function CompDocSidebar({ const cfg = DOC_STATE_CFG[docState]; const StateIcon = cfg.icon; - const editRow = ( - label: string, - value: string, - onChange: (v: string) => void, - type = "text", - placeholder = "", - ) => ( -
-
- {label} -
- onChange(e.target.value)} - readOnly={isGenerated} - placeholder={placeholder} - className={COMP_DOC_INPUT_CLS} - style={{ - ...COMP_DOC_INPUT_STYLE, - ...(isGenerated - ? { - opacity: 0.6, - cursor: "default", - pointerEvents: "none" as const, - } - : {}), - }} - /> -
- ); - return (
- {editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")} + void; + onStatusChange?: (status: WizardWOStatus) => void; + onDateCompletedChange?: (date: string) => void; +}; + +function notifyDraftSaved() { + toast( +
+ Draft saved +
, + { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + closeButton: false, + icon: false, + className: "!min-h-0 !bg-transparent !p-0 !shadow-none", + }, + ); +} + +export function useCompDocDialog({ + row, + open, + data, + onSave, + onStatusChange, + onDateCompletedChange, +}: UseCompDocDialogArgs) { + 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); + }; + + const syncDateIfChanged = () => { + if (editDateCompleted !== info.scheduledOn) { + onDateCompletedChange?.(editDateCompleted); + } + }; + + function handleMarkNotRequired() { + setDocState("not-required"); + persist({}, "NN"); + } + + function handleUndo() { + setDocState("draft"); + persist({}, "No"); + } + + function handleSaveDraft() { + persist({}, "No"); + syncDateIfChanged(); + notifyDraftSaved(); + } + + function handleGenerate() { + const ts = fmtDocNow(); + setGeneratedAt(ts); + setDocState("generated"); + persist({ generatedAt: ts }, "Yes"); + syncDateIfChanged(); + onStatusChange?.("Completed"); + } + + const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({ + info, + signOffAttachment, + rowVersion, + notes, + generatedAt, + docFields, + uploadCompletionMutation, + persist, + syncDateIfChanged, + setGeneratedAt, + markGenerated: () => setDocState("generated"), + }); + + return { + info, + templateUrl, + signOffAttachment, + rowVersion, + isDocumentsReadOnly, + site, + notes, + setNotes, + docState, + generatedAt, + editDateCompleted, + setEditDateCompleted, + amazonRep, + seaRep, + cfg, + isNotRequired, + isGenerated, + isUploading, + pdfInputRef, + handleMarkNotRequired, + handleUndo, + handleSaveDraft, + handleGenerate, + handleDownloadPdf, + handleUploadPdf, + }; +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts new file mode 100644 index 00000000..497fa679 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts @@ -0,0 +1,81 @@ +import { toast } from "react-toastify"; +import type { + CompDocData, + CompDocSaveStatus, + DocFields, +} from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html"; +import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state"; +import { + formatMediaSizeLimit, + isWorkOrderFileTooLarge, +} from "@/domain/work-orders/utils/work-order-media-limits"; + +type UseCompDocPdfActionsArgs = { + info: WorkOrderTableRow; + signOffAttachment?: string | null; + rowVersion?: string; + notes: string; + generatedAt: string; + docFields: DocFields; + uploadCompletionMutation: ReturnType; + persist: (extra: Partial, status: CompDocSaveStatus) => void; + syncDateIfChanged: () => void; + setGeneratedAt: (value: string) => void; + markGenerated: () => void; +}; + +export function useCompDocPdfActions({ + info, + signOffAttachment, + rowVersion, + notes, + generatedAt, + docFields, + uploadCompletionMutation, + persist, + syncDateIfChanged, + setGeneratedAt, + markGenerated, +}: UseCompDocPdfActionsArgs) { + 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); + markGenerated(); + persist({ generatedAt: ts }, "Yes"); + syncDateIfChanged(); + }, + }, + ); + } + + return { handleDownloadPdf, handleUploadPdf }; +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx new file mode 100644 index 00000000..51c262be --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx @@ -0,0 +1,92 @@ +import type { RefObject } from "react"; +import { Ban, CheckCircle2, FileText, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; + +type SlideOverCompletionActionsProps = { + docStatus: CompletionDocStatus; + rowVersion?: string | number | null; + completionPending: boolean; + hasUploadHandler: boolean; + pdfInputRef: RefObject; + onPdfSelected: (files: FileList | null) => void; + onOpenCompletionDoc: () => void; +}; + +function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) { + if (docStatus === "Yes") { + return ( + <> + + View / Edit Document + + ); + } + if (docStatus === "NN") { + return ( + <> + + Document Not Required — Review + + ); + } + return ( + <> + + Open completion document + + ); +} + +export function SlideOverCompletionActions({ + docStatus, + rowVersion, + completionPending, + hasUploadHandler, + pdfInputRef, + onPdfSelected, + onOpenCompletionDoc, +}: SlideOverCompletionActionsProps) { + return ( +
+ {hasUploadHandler && ( + <> + + { + onPdfSelected(event.target.files); + event.target.value = ""; + }} + /> + {!rowVersion && ( + + Waiting for work order version before upload is available. + + )} + + )} + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx new file mode 100644 index 00000000..5eafe394 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx @@ -0,0 +1,40 @@ +import { Image as ImageIcon } from "lucide-react"; +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 { MediaFile } from "@/domain/work-orders/types/work-order-media"; + +type SlideOverCompletionMediaProps = { + isReadOnly: boolean; + media: MediaFile[]; + mediaPending: boolean; + onUploadMedia?: (files: File[], category: UploadableMediaCategory) => void; + onRemoveMedia?: (id: string) => void; +}; + +export function SlideOverCompletionMedia({ + isReadOnly, + media, + mediaPending, + onUploadMedia, + onRemoveMedia, +}: SlideOverCompletionMediaProps) { + return ( +
+
+ + + Photos & Videos + +
+
+ +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx new file mode 100644 index 00000000..e9310484 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-readonly-status.tsx @@ -0,0 +1,45 @@ +import { CheckCircle2, FileText } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; + +type SlideOverCompletionReadonlyStatusProps = { + docStatus: CompletionDocStatus; + onOpenCompletionDoc: () => void; +}; + +export function SlideOverCompletionReadonlyStatus({ + docStatus, + onOpenCompletionDoc, +}: SlideOverCompletionReadonlyStatusProps) { + if (docStatus === "Yes") { + return ( + + ); + } + + return ( +
+ + + {docStatus === "NN" + ? "No completion document was required for this work order." + : "No completion document was generated."} + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx new file mode 100644 index 00000000..a0e51bf5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-signoffs.tsx @@ -0,0 +1,35 @@ +import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; + +type SlideOverCompletionSignoffsProps = { + completion?: WorkOrderBoardCompletion; +}; + +export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) { + if (!completion || completion.dispatchSignoffs.length === 0) return null; + + return ( +
+ + Dispatch sign-offs + + {completion.dispatchSignoffs.map((signoff, index) => ( +
+ {signoff.signoffType || "Sign-off"} + {signoff.name ? ` — ${signoff.name}` : ""} + {signoff.signedAt ? ` · ${signoff.signedAt}` : ""} +
+ ))} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index a1fdc549..36ae1974 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -1,9 +1,6 @@ import { useRef } from "react"; -import { Ban, CheckCircle2, Download, FileText, Image as ImageIcon, Upload } from "lucide-react"; +import { Download, FileText } from "lucide-react"; import { toast } from "react-toastify"; -import { Button } from "@/components/ui/button"; -import { Text } from "@/components/ui/text"; -import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; @@ -13,6 +10,10 @@ import { formatMediaSizeLimit, isWorkOrderFileTooLarge, } from "@/domain/work-orders/utils/work-order-media-limits"; +import { SlideOverCompletionActions } from "./slide-over-completion-actions"; +import { SlideOverCompletionMedia } from "./slide-over-completion-media"; +import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status"; +import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs"; function isMediaWO(type: WOType | ""): boolean { return type === "Emergency" || type === "Reactive"; @@ -50,23 +51,13 @@ export function SlideOverCompletionTab({ if (isMediaWO(row.type)) { return ( -
-
- - - Photos & Videos - -
-
- -
-
+ ); } @@ -120,115 +111,23 @@ export function SlideOverCompletionTab({ )} {isReadOnly ? ( - docStatus === "Yes" ? ( - - ) : ( -
- - - {docStatus === "NN" - ? "No completion document was required for this work order." - : "No completion document was generated."} - -
- ) + ) : ( -
- {Boolean(onUploadCompletionPdf) && ( - <> - - { - handlePdfSelected(event.target.files); - event.target.value = ""; - }} - /> - {!row.rowVersion && ( - - Waiting for work order version before upload is available. - - )} - - )} - -
+ )} - {completion != null && completion.dispatchSignoffs.length > 0 && ( -
- - Dispatch sign-offs - - {completion.dispatchSignoffs.map((signoff, index) => ( -
- {signoff.signoffType || "Sign-off"} - {signoff.name ? ` — ${signoff.name}` : ""} - {signoff.signedAt ? ` · ${signoff.signedAt}` : ""} -
- ))} -
- )} +
); } diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx new file mode 100644 index 00000000..5282cda2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-doc-badge.tsx @@ -0,0 +1,44 @@ +import { Image as ImageIcon } from "lucide-react"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +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" }, +}; + +export function DocBadge({ row }: { row: WorkOrderTableRow }) { + if (isMediaWO(row.type)) { + return ( + + + {row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"} + + ); + } + + const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No"; + const style = DOC_BADGE_STYLE[key]; + const label = key === "NN" ? "NN" : key; + + return ( + + {label} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx new file mode 100644 index 00000000..8d7bc8ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-assigned-field.tsx @@ -0,0 +1,28 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; + +type SlideOverInfoTabAssignedFieldProps = { + dispatcher?: WorkOrderUser; + dispatcherName?: string; +}; + +export function SlideOverInfoTabAssignedField({ + dispatcher, + dispatcherName, +}: SlideOverInfoTabAssignedFieldProps) { + if (!dispatcher && !dispatcherName) { + return —; + } + + return ( + + {dispatcher != null && ( + + )} + {dispatcher?.name || dispatcherName} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx new file mode 100644 index 00000000..ec9c3479 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -0,0 +1,92 @@ +import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row"; +import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; +import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label"; +import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils"; +import { DocBadge } from "./slide-over-doc-badge"; +import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field"; +import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field"; +import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field"; + +type SlideOverInfoTabDisplayViewProps = { + source: WorkOrderTableRow; + isCancelled: boolean; + dispatcher?: WorkOrderUser; + vendors: VendorDropdownItem[]; +}; + +export function SlideOverInfoTabDisplayView({ + source, + isCancelled, + dispatcher, + vendors, +}: SlideOverInfoTabDisplayViewProps) { + const appt = parseApptTime(source.apptTime); + const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—"; + + return ( +
+ + {source.woNumber || "—"} + + + + {source.site || "—"} + + + + {source.type ? ( + + ) : ( + — + )} + + + + + + + {source.scheduleWeekOnly && source.targetWeek + ? `Week of ${source.targetWeek}` + : source.scheduledOn || "—"} + + + + {source.dueDate || "—"} + + + {apptDisplay} + + + {source.pm ? ( + + + {source.pm} + + ) : ( + — + )} + + + + + + + + + + + + + {source.pocName || "—"} + {source.pocPhone ? ` · ${source.pocPhone}` : ""} + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx new file mode 100644 index 00000000..7eaf94f3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -0,0 +1,160 @@ +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 { 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 { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; +import { DocBadge } from "./slide-over-doc-badge"; + +type SlideOverInfoTabEditViewProps = { + draft: WorkOrderTableRow; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + allOrders: WorkOrderTableRow[]; + onDraftChange: (patch: Partial) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; +}; + +export function SlideOverInfoTabEditView({ + draft, + users, + sites, + vendors, + pmTypes, + allOrders, + onDraftChange, + onDuplicateFound, +}: SlideOverInfoTabEditViewProps) { + return ( +
+ + onDraftChange({ woNumber })} + onDuplicateFound={onDuplicateFound} + /> + + + + onDraftChange({ + site: patch.site, + location: patch.location, + locationId: patch.locationId, + }) + } + /> + + + onDraftChange({ type })} /> + + + onDraftChange({ dispatcherId: id, dispatcherName: name })} + /> + + + + onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false }) + } + onSaveWeek={(weekStart) => + onDraftChange({ + scheduledOn: "", + targetWeek: weekStart, + scheduleWeekOnly: true, + isPastDue: false, + }) + } + /> + + + onDraftChange({ dueDate })} /> + + + onDraftChange({ apptTime })} + /> + + + onDraftChange({ pm })} + onSaveDetails={(details) => + onDraftChange({ + serviceNotes: details.serviceNotes, + extraServices: details.extraServices, + }) + } + /> + + + + onDraftChange({ + vendorId: patch.vendorId, + company: patch.company, + tech: patch.tech, + techPhone: patch.techPhone, + }) + } + /> + + + onDraftChange({ status })} + onOpenSlideOver={() => {}} + /> + + + + + + + {draft.pocName || "—"} + {draft.pocPhone ? ` · ${draft.pocPhone}` : ""} + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx new file mode 100644 index 00000000..a01d741d --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-status-field.tsx @@ -0,0 +1,26 @@ +import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type SlideOverInfoTabStatusFieldProps = { + source: WorkOrderTableRow; +}; + +export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) { + if (source.isPastDue) { + return ( + + + Past Due + + ); + } + + if (source.status) { + return ; + } + + return —; +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx new file mode 100644 index 00000000..389ff87e --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type SlideOverInfoTabVendorFieldProps = { + source: WorkOrderTableRow; + vendors: VendorDropdownItem[]; +}; + +export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) { + const [vendorDlg, setVendorDlg] = useState(false); + + if (!source.tech && !source.company) { + return —; + } + + return ( + <> + + setVendorDlg(false)} + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index 66b68221..c8d9e09b 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -1,69 +1,11 @@ -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 ( - - - {row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"} - - ); - } - - const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No"; - const style = DOC_BADGE_STYLE[key]; - const label = key === "NN" ? "NN" : key; - - return ( - - {label} - - ); -} +import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view"; +import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view"; type SlideOverInfoTabProps = { row: WorkOrderTableRow; @@ -92,242 +34,31 @@ export function SlideOverInfoTab({ 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 isEditing = editing && !isCancelled; + const source = isEditing ? draft : row; const dispatcher = users.find((u) => String(u.id) === source.dispatcherId); - if (editing && !isCancelled) { + if (isEditing) { return ( -
- - onDraftChange({ woNumber })} - onDuplicateFound={onDuplicateFound} - /> - - - - onDraftChange({ - site: patch.site, - location: patch.location, - locationId: patch.locationId, - }) - } - /> - - - onDraftChange({ type })} /> - - - onDraftChange({ dispatcherId: id, dispatcherName: name })} - /> - - - - onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false }) - } - onSaveWeek={(weekStart) => - onDraftChange({ - scheduledOn: "", - targetWeek: weekStart, - scheduleWeekOnly: true, - isPastDue: false, - }) - } - /> - - - onDraftChange({ dueDate })} /> - - - onDraftChange({ apptTime })} - /> - - - onDraftChange({ pm })} - onSaveDetails={(details) => - onDraftChange({ - serviceNotes: details.serviceNotes, - extraServices: details.extraServices, - }) - } - /> - - - - onDraftChange({ - vendorId: patch.vendorId, - company: patch.company, - tech: patch.tech, - techPhone: patch.techPhone, - }) - } - /> - - - onDraftChange({ status })} - onOpenSlideOver={() => {}} - /> - - - - - - - {draft.pocName || "—"} - {draft.pocPhone ? ` · ${draft.pocPhone}` : ""} - - -
+ ); } return ( -
- - {source.woNumber || "—"} - - - - {source.site || "—"} - - - - {source.type ? ( - - ) : ( - — - )} - - - {dispatcher || source.dispatcherName ? ( - - {dispatcher != null && ( - - )} - {dispatcher?.name || source.dispatcherName} - - ) : ( - — - )} - - - - {source.scheduleWeekOnly && source.targetWeek - ? `Week of ${source.targetWeek}` - : source.scheduledOn || "—"} - - - - {source.dueDate || "—"} - - - {apptDisplay} - - - {source.pm ? ( - - - {source.pm} - - ) : ( - — - )} - - - {source.tech || source.company ? ( - - ) : ( - — - )} - setVendorDlg(false)} - /> - - - {source.isPastDue ? ( - - - Past Due - - ) : source.status ? ( - - ) : ( - — - )} - - - - - - - {source.pocName || "—"} - {source.pocPhone ? ` · ${source.pocPhone}` : ""} - - -
+ ); } diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts new file mode 100644 index 00000000..cb0e7fa2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -0,0 +1,145 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { + buildSlideOverPatch, + isSlideOverDraftDirty, +} from "@/domain/work-orders/utils/slide-over-draft"; +import type { SlideOverSaveOptions } from "./work-order-slide-over"; +import type { SlideOverTab } from "./use-work-order-slide-over"; + +type UseSlideOverEditStateArgs = { + row: WorkOrderTableRow | null; + editMode: boolean; + infoSource: WorkOrderTableRow | null | undefined; + onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; + onClose: () => void; + closeDisabled: boolean; + setTab: (tab: SlideOverTab) => void; +}; + +export function useSlideOverEditState({ + row, + editMode, + infoSource, + onSave, + onClose, + closeDisabled, + setTab, +}: UseSlideOverEditStateArgs) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(null); + /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ + const [baseline, setBaseline] = useState(null); + const [showUnsaved, setShowUnsaved] = useState(false); + + // Reset draft/baseline only when the opened WO identity or editMode intent changes — + // not on every optimistic parent-row field update during a failed/in-flight save. + useEffect(() => { + if (!row) { + setDraft(null); + setBaseline(null); + setEditing(false); + setShowUnsaved(false); + return; + } + setDraft(row); + setBaseline(row); + setEditing(editMode && (row.status as string) !== "Canceled"); + setTab("info"); + setShowUnsaved(false); + // Intentionally identity-only: depending on `row` would absorb optimistic patches. + // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate + }, [row?.id, editMode]); + + useEffect(() => { + if (!infoSource) return; + if (!editing) { + setDraft(infoSource); + setBaseline(infoSource); + return; + } + // While editing, freeze baseline so optimistic parent-row patches cannot clear dirty. + setDraft((prev) => { + if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev; + return infoSource; + }); + }, [infoSource, editing, baseline]); + + const activeDraft = draft ?? infoSource; + const dirtyBaseline = baseline ?? infoSource; + const isDirty = useMemo(() => { + if (!dirtyBaseline || !activeDraft || !editing) return false; + return isSlideOverDraftDirty(activeDraft, dirtyBaseline); + }, [activeDraft, dirtyBaseline, editing]); + + const handleDraftChange = (patch: Partial) => { + setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + }; + + const commit = () => { + if (!row || !activeDraft || !dirtyBaseline) return; + const patch = buildSlideOverPatch(activeDraft, dirtyBaseline); + if (Object.keys(patch).length === 0) { + setEditing(false); + setShowUnsaved(false); + return; + } + onSave(row.id, patch, { + onSuccess: () => { + setBaseline(activeDraft); + setEditing(false); + setShowUnsaved(false); + }, + }); + }; + + const discard = () => { + if (!dirtyBaseline) return; + setDraft(dirtyBaseline); + setEditing(false); + setShowUnsaved(false); + }; + + const requestClose = () => { + if (closeDisabled) return; + if (editing && isDirty) { + setShowUnsaved(true); + return; + } + onClose(); + }; + + const handleCancelEdit = () => { + if (isDirty) { + setShowUnsaved(true); + return; + } + if (dirtyBaseline) setDraft(dirtyBaseline); + setEditing(false); + }; + + const startEditing = () => { + if (!infoSource) return; + setDraft(infoSource); + setBaseline(infoSource); + setEditing(true); + setTab("info"); + }; + + return { + editing, + activeDraft, + isDirty, + showUnsaved, + setShowUnsaved, + handleDraftChange, + commit, + discard, + requestClose, + handleCancelEdit, + startEditing, + }; +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts new file mode 100644 index 00000000..588f795f --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -0,0 +1,56 @@ +import { useMemo, useState } from "react"; +import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads"; +import type { + useDeleteWorkOrderMedia, + useUploadWorkOrderMedia, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; + +const COMPLETION_MEDIA_CATEGORIES = new Set(["Before", "After", "Completion"]); + +type UseSlideOverMediaStateArgs = { + detailForRow?: WorkOrderBoardDetail; + uploadMediaMutation: ReturnType; + deleteMediaMutation: ReturnType; +}; + +export function useSlideOverMediaState({ + detailForRow, + uploadMediaMutation, + deleteMediaMutation, +}: UseSlideOverMediaStateArgs) { + const [mediaBatchPending, setMediaBatchPending] = useState(false); + + const completionMedia = useMemo( + () => + boardMediaItemsToMediaFiles( + (detailForRow?.media ?? []).filter((item) => + COMPLETION_MEDIA_CATEGORIES.has(item.category), + ), + ), + [detailForRow?.media], + ); + + const extrasUploads = useMemo( + () => + boardMediaItemsToMediaFiles( + (detailForRow?.media ?? []).filter((item) => item.category === "Extra"), + ), + [detailForRow?.media], + ); + + const mediaPending = + mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending; + + const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => { + await runSequentialUploads( + files, + (file) => uploadMediaMutation.mutateAsync({ file, category }), + setMediaBatchPending, + ); + }; + + return { completionMedia, extrasUploads, mediaPending, uploadMediaFiles }; +} diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts new file mode 100644 index 00000000..23f4e427 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { toast } from "react-toastify"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { + useAddWorkOrderComment, + useDeleteWorkOrderMedia, + useUpdateWorkOrderComment, + useUploadCompletionDoc, + useUploadWorkOrderMedia, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; +import { useAuthContext } from "@/providers/auth-context"; +import { useSlideOverEditState } from "./use-slide-over-edit-state"; +import { useSlideOverMediaState } from "./use-slide-over-media-state"; +import type { SlideOverSaveOptions } from "./work-order-slide-over"; + +export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras"; + +type UseWorkOrderSlideOverArgs = { + row: WorkOrderTableRow | null; + editMode: boolean; + onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; + onClose: () => void; + saving?: boolean; +}; + +export function useWorkOrderSlideOver({ + row, + editMode, + onSave, + onClose, + saving, +}: UseWorkOrderSlideOverArgs) { + const { user } = useAuthContext(); + const workOrderId = row?.id; + const { + data: detail, + isLoading, + isFetching, + error, + refetch, + } = useWorkOrderBoardDetail(workOrderId, Boolean(row)); + const commentMutation = useAddWorkOrderComment(workOrderId ?? ""); + const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? ""); + const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? ""); + const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? ""); + const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); + + const [tab, setTab] = useState("info"); + + // 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); + + const { completionMedia, extrasUploads, mediaPending, uploadMediaFiles } = useSlideOverMediaState( + { + detailForRow, + uploadMediaMutation, + deleteMediaMutation, + }, + ); + const completionPending = uploadCompletionMutation.isPending; + const closeDisabled = Boolean(saving || mediaPending || completionPending); + + const editState = useSlideOverEditState({ + row, + editMode, + infoSource, + onSave, + onClose, + closeDisabled, + setTab, + }); + + const uploadCompletionPdf = (file: File) => { + const version = infoSource?.rowVersion; + if (!version) { + toast.error("Missing work order version. Refresh and try again."); + return; + } + uploadCompletionMutation.mutate({ file, workOrderVersion: version }); + }; + + const showDetailSkeleton = isLoading && !detailForRow; + const errorMessage = + error instanceof Error ? error.message : "Failed to load work order details."; + + return { + user, + detail, + isLoading, + isFetching, + error, + refetch, + commentMutation, + updateCommentMutation, + tab, + setTab, + detailForRow, + infoSource, + isCancelled, + isDocumentsReadOnly, + completionMedia, + extrasUploads, + mediaPending, + completionPending, + closeDisabled, + uploadMediaFiles, + deleteMediaMutation, + uploadCompletionPdf, + showDetailSkeleton, + errorMessage, + ...editState, + }; +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx new file mode 100644 index 00000000..7b72c393 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-activity-section.tsx @@ -0,0 +1,101 @@ +import { useMemo, useState } from "react"; +import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +import { CommentTimeline } from "@/components/common/comment-timeline"; +import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; +import { Button } from "@/components/ui/button"; +import { AuditTimeline } from "./audit-timeline"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; + +type ActivityTab = "customer" | "internal" | "audit"; + +type WorkOrderActivitySectionProps = { + wo: WorkOrderDetail; + onCommentSubmit: ( + text: string, + commentType: "customer" | "internal", + options?: { onSuccess?: () => void }, + ) => void; + isCommentPending: boolean; +}; + +export function WorkOrderActivitySection({ + wo, + onCommentSubmit, + isCommentPending, +}: WorkOrderActivitySectionProps) { + const [activeTab, setActiveTab] = useState("customer"); + const [commentText, setCommentText] = useState(""); + + const customerComments = useMemo( + () => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"), + [wo.comments], + ); + const internalComments = useMemo( + () => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"), + [wo.comments], + ); + const auditLog = wo.auditLog ?? []; + + const handleCommentSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!commentText.trim()) { + return; + } + onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", { + onSuccess: () => setCommentText(""), + }); + }; + + return ( + + + Activity + + setActiveTab(value)} + className="mb-3" + > + + + + + + {activeTab === "customer" && ( + + )} + {activeTab === "internal" && ( + + )} + {activeTab === "audit" && } + + {activeTab === "internal" && ( + + setCommentText(event.target.value)} + placeholder="Type a comment..." + disabled={isCommentPending} + /> + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx new file mode 100644 index 00000000..ec67beae --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-description-accordion.tsx @@ -0,0 +1,20 @@ +import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; + +type WorkOrderDescriptionAccordionProps = { + wo: WorkOrderDetail; +}; + +export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) { + return ( + + }> + Description + + + {wo.description || "No description provided."} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx index 49079c32..d1ba2d09 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-detail-sections.tsx @@ -1,32 +1,9 @@ -import { useMemo, useState } from "react"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Paper, - Tab, - Tabs, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Typography, -} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { CommentTimeline } from "@/components/common/comment-timeline"; -import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; -import { DetailField, DetailFieldGrid } from "@/components/common/detail-field"; -import { Button } from "@/components/ui/button"; -import { AuditTimeline } from "./audit-timeline"; -import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu"; import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order"; -import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; - -type ActivityTab = "customer" | "internal" | "audit"; +import { WorkOrderActivitySection } from "./work-order-activity-section"; +import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion"; +import { WorkOrderDetailsCard } from "./work-order-details-card"; +import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion"; +import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions"; type WorkOrderDetailsSectionProps = { wo: WorkOrderDetail; @@ -51,219 +28,22 @@ export function WorkOrderDetailsSection({ onCommentSubmit, isCommentPending, }: WorkOrderDetailsSectionProps) { - const [activeTab, setActiveTab] = useState("customer"); - const [commentText, setCommentText] = useState(""); - - const customerComments = useMemo( - () => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"), - [wo.comments], - ); - const internalComments = useMemo( - () => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"), - [wo.comments], - ); - const auditLog = wo.auditLog ?? []; - - const handleCommentSubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (!commentText.trim()) { - return; - } - onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", { - onSuccess: () => setCommentText(""), - }); - }; - return ( <> - - - Details - - - - - - - - - Assigned To - - - - - - - - - - }> - Classification - - - - - - - - - - - - }> - Schedule - - - - - - - - - - - - - }> - Financial - - - - - - - - - - - - }> - Dispatches ({wo.dispatches.length}) - - - {wo.dispatches.length ? ( - - - - - Dispatch # - Vendor - NTE - Status - Date - Wait - - - - {wo.dispatches.map((dispatch) => { - const color = waitTimeColor(dispatch.dispatchedAt); - return ( - onDispatchClick(dispatch.id)} - > - {dispatch.dispatchNumber || dispatch.poNumber} - {dispatch.vendorName} - - {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} - - {dispatch.status} - - {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} - - - {dispatch.status === "Sent" ? ( - - {timeSince(dispatch.dispatchedAt)} - - ) : ( - "—" - )} - - - ); - })} - -
-
- ) : ( - No dispatches yet. - )} -
-
- - - }> - Description - - - {wo.description || "No description provided."} - - - - - - Activity - - setActiveTab(value)} - className="mb-3" - > - - - - - - {activeTab === "customer" && ( - - )} - {activeTab === "internal" && ( - - )} - {activeTab === "audit" && } - - {activeTab === "internal" && ( - - setCommentText(event.target.value)} - placeholder="Type a comment..." - disabled={isCommentPending} - /> - - - )} - + + + + + ); } diff --git a/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx new file mode 100644 index 00000000..fb332ce1 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-details-card.tsx @@ -0,0 +1,45 @@ +import { Box, Paper, Typography } from "@mui/material"; +import { DetailField, DetailFieldGrid } from "@/components/common/detail-field"; +import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu"; +import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +type WorkOrderDetailsCardProps = { + wo: WorkOrderDetail; + users: WorkOrderUser[]; + onAssign: (userId: string | number, userName: string) => void; + isAssignPending?: boolean; +}; + +export function WorkOrderDetailsCard({ + wo, + users, + onAssign, + isAssignPending = false, +}: WorkOrderDetailsCardProps) { + return ( + + + Details + + + + + + + + + Assigned To + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx new file mode 100644 index 00000000..30de5b9e --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-dispatches-accordion.tsx @@ -0,0 +1,85 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; + +type WorkOrderDispatchesAccordionProps = { + wo: WorkOrderDetail; + onDispatchClick: (dispatchId: string | number) => void; +}; + +export function WorkOrderDispatchesAccordion({ + wo, + onDispatchClick, +}: WorkOrderDispatchesAccordionProps) { + return ( + + }> + Dispatches ({wo.dispatches.length}) + + + {wo.dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + + + + {wo.dispatches.map((dispatch) => { + const color = waitTimeColor(dispatch.dispatchedAt); + return ( + onDispatchClick(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + {dispatch.status === "Sent" ? ( + + {timeSince(dispatch.dispatchedAt)} + + ) : ( + "—" + )} + + + ); + })} + +
+
+ ) : ( + No dispatches yet. + )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx new file mode 100644 index 00000000..9c8b4214 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-simple-accordions.tsx @@ -0,0 +1,61 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { DetailField, DetailFieldGrid } from "@/components/common/detail-field"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import { formatDateTime } from "@/lib/time-utils"; + +type WorkOrderSimpleAccordionsProps = { + wo: WorkOrderDetail; +}; + +export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) { + return ( + <> + + }> + Classification + + + + + + + + + + + + }> + Schedule + + + + + + + + + + + + + }> + Financial + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx new file mode 100644 index 00000000..2efcac54 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx @@ -0,0 +1,52 @@ +import type { SlideOverTab } from "./use-work-order-slide-over"; + +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 WorkOrderSlideOverTabNavProps = { + tab: SlideOverTab; + saving?: boolean; + onTabChange: (tab: SlideOverTab) => void; +}; + +export function WorkOrderSlideOverTabNav({ + tab, + saving, + onTabChange, +}: WorkOrderSlideOverTabNavProps) { + return ( +
+ {SLIDE_OVER_TABS.map((item) => { + const active = tab === item.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx new file mode 100644 index 00000000..9f682b89 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-panel.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton"; + +type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras"; + +type WorkOrderSlideOverTabPanelProps = { + variant: SlideOverTabSkeletonVariant; + showSkeleton: boolean; + hasError: boolean; + errorMessage: string; + onRetry: () => void; + showContent: boolean; + children: ReactNode; +}; + +export function WorkOrderSlideOverTabPanel({ + variant, + showSkeleton, + hasError, + errorMessage, + onRetry, + showContent, + children, +}: WorkOrderSlideOverTabPanelProps) { + return ( + <> + {showSkeleton && } + {hasError && } + {showContent && children} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx new file mode 100644 index 00000000..db677926 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -0,0 +1,154 @@ +import { Box } from "@mui/material"; +import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab"; +import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-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 { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab"; +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 { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over"; +import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; + +type SlideOverState = ReturnType; + +type WorkOrderSlideOverTabsProps = { + tab: SlideOverTab; + state: SlideOverState; + saving?: boolean; + users: WorkOrderUser[]; + sites: LocationOption[]; + vendors: VendorDropdownItem[]; + pmTypes: string[]; + allOrders: WorkOrderTableRow[]; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; + onOpenCompletionDoc: () => void; +}; + +export function WorkOrderSlideOverTabs({ + tab, + state, + saving, + users, + sites, + vendors, + pmTypes, + allOrders, + onDuplicateFound, + onOpenCompletionDoc, +}: WorkOrderSlideOverTabsProps) { + const hasError = Boolean(state.error) && !state.detailForRow; + const showContent = Boolean(state.detailForRow || !state.isLoading); + const { infoSource, activeDraft } = state; + + return ( + + {tab === "info" && infoSource != null && activeDraft != null && ( + + )} + + {tab === "comments" && ( + void state.refetch()} + showContent={state.detailForRow != null} + > + {state.detailForRow != null && ( + state.commentMutation.mutate({ text }, options)} + onUpdateComment={(commentId, text, options) => + state.updateCommentMutation.mutate({ commentId, text }, options) + } + /> + )} + + )} + + {tab === "audit" && ( + void state.refetch()} + showContent={state.detailForRow != null} + > + {state.detailForRow != null && ( + + )} + + )} + + {tab === "completion" && state.infoSource != null && ( + void state.refetch()} + showContent={showContent} + > + { + void state.uploadMediaFiles(files, category); + }} + onRemoveMedia={(id) => state.deleteMediaMutation.mutate(id)} + onUploadCompletionPdf={state.uploadCompletionPdf} + onOpenCompletionDoc={onOpenCompletionDoc} + /> + + )} + + {tab === "extras" && ( + void state.refetch()} + showContent={showContent} + > + { + void state.uploadMediaFiles(files, "Extra"); + }} + onRemove={(id) => state.deleteMediaMutation.mutate(id)} + /> + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 86864503..e6e21ca6 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -1,16 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; import { Box } from "@mui/material"; -import { toast } from "react-toastify"; import { SideSheet } from "@/components/ui/side-sheet"; -import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header"; import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer"; -import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab"; -import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab"; -import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab"; -import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; -import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; -import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton"; import type { LocationOption, VendorDropdownItem, @@ -20,33 +11,9 @@ import type { WorkOrderTablePatch, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; -import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; -import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useAddWorkOrderComment, - useDeleteWorkOrderMedia, - useUpdateWorkOrderComment, - useUploadCompletionDoc, - useUploadWorkOrderMedia, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { - buildSlideOverPatch, - isSlideOverDraftDirty, -} from "@/domain/work-orders/utils/slide-over-draft"; -import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads"; -import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; -import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; -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" }, -]; +import { useWorkOrderSlideOver } from "./use-work-order-slide-over"; +import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav"; +import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; export type SlideOverSaveOptions = { onSuccess?: () => void; @@ -84,166 +51,14 @@ export function WorkOrderSlideOver({ onClose, saving, }: WorkOrderSlideOverProps) { - const { user } = useAuthContext(); - const workOrderId = row?.id; - const { - data: detail, - isLoading, - isFetching, - error, - refetch, - } = useWorkOrderBoardDetail(workOrderId, Boolean(row)); - const commentMutation = useAddWorkOrderComment(workOrderId ?? ""); - const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? ""); - const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? ""); - const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? ""); - const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); - - const [tab, setTab] = useState("info"); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(null); - /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ - const [baseline, setBaseline] = useState(null); - const [showUnsaved, setShowUnsaved] = useState(false); - /** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */ - const [mediaBatchPending, setMediaBatchPending] = 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); - - // Reset draft/baseline only when the opened WO identity or editMode intent changes — - // not on every optimistic parent-row field update during a failed/in-flight save. - useEffect(() => { - if (!row) { - setDraft(null); - setBaseline(null); - setEditing(false); - setShowUnsaved(false); - return; - } - setDraft(row); - setBaseline(row); - setEditing(editMode && (row.status as string) !== "Canceled"); - setTab("info"); - setShowUnsaved(false); - // Intentionally identity-only: depending on `row` would absorb optimistic patches. - // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate - }, [row?.id, editMode]); - - useEffect(() => { - if (!infoSource) return; - if (!editing) { - setDraft(infoSource); - setBaseline(infoSource); - return; - } - // While editing, freeze baseline so optimistic parent-row patches cannot clear dirty. - setDraft((prev) => { - if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev; - return infoSource; - }); - }, [infoSource, editing, baseline]); - - const activeDraft = draft ?? infoSource; - const dirtyBaseline = baseline ?? infoSource; - const isDirty = useMemo(() => { - if (!dirtyBaseline || !activeDraft || !editing) return false; - return isSlideOverDraftDirty(activeDraft, dirtyBaseline); - }, [activeDraft, dirtyBaseline, editing]); - - 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 = - mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending; - const completionPending = uploadCompletionMutation.isPending; - const closeDisabled = Boolean(saving || mediaPending || completionPending); - - const handleDraftChange = (patch: Partial) => { - setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); - }; - - const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => { - await runSequentialUploads( - files, - (file) => uploadMediaMutation.mutateAsync({ file, category }), - setMediaBatchPending, - ); - }; - - const commit = () => { - if (!row || !activeDraft || !dirtyBaseline) return; - const patch = buildSlideOverPatch(activeDraft, dirtyBaseline); - if (Object.keys(patch).length === 0) { - setEditing(false); - setShowUnsaved(false); - return; - } - onSave(row.id, patch, { - onSuccess: () => { - setBaseline(activeDraft); - setEditing(false); - setShowUnsaved(false); - }, - }); - }; - - const discard = () => { - if (!dirtyBaseline) return; - setDraft(dirtyBaseline); - setEditing(false); - setShowUnsaved(false); - }; - - const requestClose = () => { - if (closeDisabled) return; - if (editing && isDirty) { - setShowUnsaved(true); - return; - } - onClose(); - }; - - const handleCancelEdit = () => { - if (isDirty) { - setShowUnsaved(true); - return; - } - if (dirtyBaseline) setDraft(dirtyBaseline); - setEditing(false); - }; - - const showDetailSkeleton = isLoading && !detailForRow; - const errorMessage = - error instanceof Error ? error.message : "Failed to load work order details."; + const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving }); + const { infoSource, activeDraft } = state; return ( @@ -251,170 +66,43 @@ export function WorkOrderSlideOver({ -
- {SLIDE_OVER_TABS.map((item) => { - const active = tab === item.id; - return ( - - ); - })} -
+ - - {tab === "info" && ( - - )} - - {tab === "comments" && ( - <> - {showDetailSkeleton && } - {Boolean(error) && !detailForRow && ( - void refetch()} /> - )} - {detailForRow != null && ( - commentMutation.mutate({ text }, options)} - onUpdateComment={(commentId, text, options) => - updateCommentMutation.mutate({ commentId, text }, options) - } - /> - )} - - )} - - {tab === "audit" && ( - <> - {showDetailSkeleton && } - {Boolean(error) && !detailForRow && ( - void refetch()} /> - )} - {detailForRow != null && ( - - )} - - )} - - {tab === "completion" && ( - <> - {showDetailSkeleton && } - {Boolean(error) && !detailForRow && ( - void refetch()} /> - )} - {Boolean(detailForRow || !isLoading) && ( - { - void uploadMediaFiles(files, 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 && } - {Boolean(error) && !detailForRow && ( - void refetch()} /> - )} - {Boolean(detailForRow || !isLoading) && ( - { - void uploadMediaFiles(files, "Extra"); - }} - onRemove={(id) => deleteMediaMutation.mutate(id)} - /> - )} - - )} - + onOpenCompletionDoc(infoSource)} + /> onCancelWO(infoSource)} - onCancelEdit={handleCancelEdit} - onClose={requestClose} - onEdit={() => { - const source = infoSource; - setDraft(source); - setBaseline(source); - setEditing(true); - setTab("info"); - }} - onSave={commit} + onCancelEdit={state.handleCancelEdit} + onClose={state.requestClose} + onEdit={state.startEditing} + onSave={state.commit} onDiscard={() => { - discard(); + state.discard(); onClose(); }} - onKeepEditing={() => setShowUnsaved(false)} + onKeepEditing={() => state.setShowUnsaved(false)} />
)} diff --git a/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx new file mode 100644 index 00000000..e17a262a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-order-cancel-dialog.tsx @@ -0,0 +1,50 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type WorkOrderCancelDialogProps = { + row: WorkOrderTableRow | null; + isPending: boolean; + onDialogClose: (() => void) | undefined; + onKeepWO: () => void; + onConfirm: () => void; +}; + +export function WorkOrderCancelDialog({ + row, + isPending, + onDialogClose, + onKeepWO, + onConfirm, +}: WorkOrderCancelDialogProps) { + return ( + + Cancel work order? + + + Cancel WO #{row?.woNumber}? The record will be kept with status Canceled. + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx new file mode 100644 index 00000000..9233d212 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -0,0 +1,94 @@ +import type { RefObject } from "react"; +import { Upload } from "lucide-react"; +import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits"; + +const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"]; + +type MediaUploaderDropzoneProps = { + inputRef: RefObject; + category: UploadableMediaCategory; + onCategoryChange: (category: UploadableMediaCategory) => void; + showCategoryPicker: boolean; + pending: boolean; + onFilesSelected: (files: FileList | null) => void; +}; + +export function MediaUploaderDropzone({ + inputRef, + category, + onCategoryChange, + showCategoryPicker, + pending, + onFilesSelected, +}: MediaUploaderDropzoneProps) { + return ( + <> + {showCategoryPicker && ( +
+ + +
+ )} + + { + onFilesSelected(e.target.files); + e.target.value = ""; + }} + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx new file mode 100644 index 00000000..55a30ea2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx @@ -0,0 +1,96 @@ +import { + File as FileIcon, + FileText, + Film, + Image as ImageIcon, + Paperclip, + Play, + X, +} from "lucide-react"; +import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits"; + +const FILETYPE_ICON: Record = { + image: ImageIcon, + video: Film, + pdf: FileText, + doc: FileIcon, + other: Paperclip, +}; + +type MediaUploaderGridItemProps = { + file: MediaFile; + readOnly: boolean; + pending: boolean; + onRemove: (id: string) => void; +}; + +export function MediaUploaderGridItem({ + file, + readOnly, + pending, + onRemove, +}: MediaUploaderGridItemProps) { + const TypeIcon = FILETYPE_ICON[file.fileType]; + const showDelete = !readOnly && canDeleteWorkOrderMedia(file); + + return ( +
+
+ {file.fileType === "image" ? ( + {file.name} + ) : file.fileType === "video" ? ( + <> +
+
+
+ {file.name} +
+
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx new file mode 100644 index 00000000..5bba5384 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx @@ -0,0 +1,32 @@ +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { MediaUploaderGridItem } from "./media-uploader-grid-item"; + +type MediaUploaderGridProps = { + uploads: MediaFile[]; + readOnly: boolean; + pending: boolean; + onRemove: (id: string) => void; +}; + +export function MediaUploaderGrid({ + uploads, + readOnly, + pending, + onRemove, +}: MediaUploaderGridProps) { + if (uploads.length === 0) return null; + + return ( +
+ {uploads.map((file) => ( + + ))} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 6b600595..106aa32b 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -1,33 +1,15 @@ import { useRef, useState } from "react"; -import { - File as FileIcon, - FileText, - Film, - Image as ImageIcon, - Paperclip, - Play, - 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 type { 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 = { - image: ImageIcon, - video: Film, - pdf: FileText, - doc: FileIcon, - other: Paperclip, -}; - -const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"]; +import { MediaUploaderDropzone } from "./media-uploader-dropzone"; +import { MediaUploaderGrid } from "./media-uploader-grid"; type MediaUploaderProps = { uploads: MediaFile[]; @@ -88,148 +70,21 @@ export function MediaUploader({ return (
{!readOnly && ( - <> - {Boolean(onUpload) && ( -
- - -
- )} - - { - addFiles(e.target.files); - e.target.value = ""; - }} - /> - - )} - {uploads.length > 0 && ( -
- {uploads.map((u) => { - const TypeIcon = FILETYPE_ICON[u.fileType]; - const showDelete = !readOnly && canDeleteWorkOrderMedia(u); - return ( -
-
- {u.fileType === "image" ? ( - {u.name} - ) : u.fileType === "video" ? ( - <> -
-
-
- {u.name} -
-
-
- ); - })} -
+ )} +
); } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts new file mode 100644 index 00000000..205082cd --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -0,0 +1,120 @@ +import { useMemo, useState } from "react"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + useWorkOrderLocations, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { guardDialogClose } from "@/lib/dialog-guard"; +import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; +import { useWorkOrdersTableData } from "./use-work-orders-table-data"; + +export function useWorkOrdersListPage() { + const [wizardOpen, setWizardOpen] = useState(false); + const [slideOverRow, setSlideOverRow] = useState(null); + const [slideOverEdit, setSlideOverEdit] = useState(false); + const [confirmCancel, setConfirmCancel] = useState(null); + const [docRow, setDocRow] = useState(null); + const [mediaRow, setMediaRow] = useState(null); + const [completionDocs, setCompletionDocs] = useState>({}); + const filters = useWorkOrdersListFilters(); + const tableData = useWorkOrdersTableData(filters); + const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({ + onPatch: tableData.patchRow, + clearPatch: tableData.clearRowPatch, + getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), + }); + + const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers(); + const { data: locations = [] } = useWorkOrderLocations(); + const { data: vendors = [] } = useWorkOrderVendors(); + const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); + + const pmTypes = useMemo( + () => problemOptions.map((option) => option.value).filter(Boolean), + [problemOptions], + ); + + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); + + const activeSlideOverRow = useMemo(() => { + if (!slideOverRow) return null; + return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow; + }, [slideOverRow, tableData.rows]); + + const activeDocRow = useMemo(() => { + if (!docRow) return null; + return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; + }, [docRow, tableData.rows]); + + const activeMediaRow = useMemo(() => { + if (!mediaRow) return null; + return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; + }, [mediaRow, tableData.rows]); + + const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { + setSlideOverRow(row); + setSlideOverEdit(edit); + }; + + const handleCloseSlideOver = () => { + setSlideOverRow(null); + setSlideOverEdit(false); + tableData.setSelectedId(null); + }; + + const handleConfirmCancel = () => { + if (!confirmCancel) return; + patchField(confirmCancel.id, { status: "Canceled" }); + setConfirmCancel(null); + handleCloseSlideOver(); + }; + + const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null)); + + const saveCompletionDoc = ( + row: WorkOrderTableRow, + data: CompDocData, + status: CompDocSaveStatus, + ) => { + setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: data })); + patchField(row.id, { docStatus: status }); + }; + + return { + wizardOpen, + setWizardOpen, + slideOverEdit, + confirmCancel, + setConfirmCancel, + docRow, + setDocRow, + mediaRow, + setMediaRow, + completionDocs, + filters, + tableData, + patchField, + isPatchPending, + users, + usersLoading, + locations, + vendors, + pmTypes, + vendorTechs, + activeSlideOverRow, + activeDocRow, + activeMediaRow, + handleOpenSlideOver, + handleCloseSlideOver, + handleConfirmCancel, + cancelDialogClose, + saveCompletionDoc, + }; +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8bb271c5..7f0d9a4f 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,14 +1,4 @@ -import { useMemo, useState } from "react"; -import { - Box, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import { Button } from "@/components/ui/button"; -import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Box } from "@mui/material"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; @@ -18,91 +8,16 @@ import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_com import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; +import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; -import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; -import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; -import type { CompDocData } from "@/domain/work-orders/types/completion-doc"; -import { - useWorkOrderLocations, - useWorkOrderVendors, -} from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -import { guardDialogClose } from "@/lib/dialog-guard"; +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; import { useAuthContext } from "@/providers/auth-context"; export default function WorkOrdersListPage() { const { user } = useAuthContext(); - const [wizardOpen, setWizardOpen] = useState(false); - const [slideOverRow, setSlideOverRow] = useState(null); - const [slideOverEdit, setSlideOverEdit] = useState(false); - const [confirmCancel, setConfirmCancel] = useState(null); - const [docRow, setDocRow] = useState(null); - const [mediaRow, setMediaRow] = useState(null); - const [completionDocs, setCompletionDocs] = useState>({}); - const filters = useWorkOrdersListFilters(); - const tableData = useWorkOrdersTableData(filters); - const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({ - onPatch: tableData.patchRow, - clearPatch: tableData.clearRowPatch, - getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), - }); - - const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers(); - const { data: locations = [] } = useWorkOrderLocations(); - const { data: vendors = [] } = useWorkOrderVendors(); - const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - - const pmTypes = useMemo( - () => problemOptions.map((option) => option.value).filter(Boolean), - [problemOptions], - ); - - const vendorTechs = useMemo( - () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], - [vendors], - ); - - const totalCount = tableData.totalCount; - const scopeTotalCount = tableData.scopeTotalCount; - - const activeSlideOverRow = useMemo(() => { - if (!slideOverRow) return null; - return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow; - }, [slideOverRow, tableData.rows]); - - const activeDocRow = useMemo(() => { - if (!docRow) return null; - return tableData.rows.find((row) => row.id === docRow.id) ?? docRow; - }, [docRow, tableData.rows]); - - const activeMediaRow = useMemo(() => { - if (!mediaRow) return null; - return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; - }, [mediaRow, tableData.rows]); - - const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => { - setSlideOverRow(row); - setSlideOverEdit(edit); - }; - - const handleCloseSlideOver = () => { - setSlideOverRow(null); - setSlideOverEdit(false); - tableData.setSelectedId(null); - }; - - const handleConfirmCancel = () => { - if (!confirmCancel) return; - patchField(confirmCancel.id, { status: "Canceled" }); - setConfirmCancel(null); - handleCloseSlideOver(); - }; - - const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null)); + const page = useWorkOrdersListPage(); + const { filters, tableData, activeDocRow } = page; return ( @@ -115,7 +30,7 @@ export default function WorkOrdersListPage() { search={filters.search} onSearchChange={filters.handleSearchChange} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} - onNewWorkOrder={() => setWizardOpen(true)} + onNewWorkOrder={() => page.setWizardOpen(true)} activeFilterCount={filters.activeFilterCount} isFetching={tableData.isFetching && !tableData.isLoading} /> @@ -129,19 +44,19 @@ export default function WorkOrdersListPage() { onDispatcherChange={filters.setDispatcherIds} typeFilter={filters.typeFilter} onTypeChange={filters.setTypeFilter} - users={users} - usersLoading={usersLoading} + users={page.users} + usersLoading={page.usersLoading} currentUserId={user?.id} - filteredCount={totalCount} - scopeTotalCount={scopeTotalCount} + filteredCount={tableData.totalCount} + scopeTotalCount={tableData.scopeTotalCount} /> {filters.advApplied != null && ( @@ -162,21 +77,21 @@ export default function WorkOrdersListPage() { filters.handleSearchChange("")} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} - onPatch={patchField} + onPatch={page.patchField} /> - + patchField(id, patch, options)} - onOpenCompletionDoc={setDocRow} - onCancelWO={setConfirmCancel} - onDuplicateFound={(existing) => handleOpenSlideOver(existing, false)} - saving={isPatchPending} - onClose={handleCloseSlideOver} + onSave={(id, patch, options) => page.patchField(id, patch, options)} + onOpenCompletionDoc={page.setDocRow} + onCancelWO={page.setConfirmCancel} + onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} + saving={page.isPatchPending} + onClose={page.handleCloseSlideOver} /> - - Cancel work order? - - - Cancel WO #{confirmCancel?.woNumber}? The record will be kept with status Canceled. - - - - - - - + page.setConfirmCancel(null)} + onConfirm={page.handleConfirmCancel} + /> - {activeMediaRow != null && ( + {page.activeMediaRow != null && ( { - if (!open) setMediaRow(null); + if (!open) page.setMediaRow(null); }} /> )} @@ -251,15 +144,12 @@ export default function WorkOrdersListPage() { row={activeDocRow} open={!!activeDocRow} onOpenChange={(open) => { - if (!open) setDocRow(null); + if (!open) page.setDocRow(null); }} - data={completionDocs[String(activeDocRow.id)]} - onSave={(data, status) => { - setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); - patchField(activeDocRow.id, { docStatus: status }); - }} - onStatusChange={(status) => patchField(activeDocRow.id, { status })} - onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })} + data={page.completionDocs[String(activeDocRow.id)]} + onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)} + onStatusChange={(status) => page.patchField(activeDocRow.id, { status })} + onDateCompletedChange={(date) => page.patchField(activeDocRow.id, { scheduledOn: date })} /> )} diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index 82667f19..b2cb79bc 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -38,7 +38,7 @@ function invalidateBoardDetail( void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) }); } -interface AddWorkOrderCommentInput { +export interface AddWorkOrderCommentInput { text: string; commentType?: "customer" | "internal"; }