From b7056f569e11d92f069a4b4dfc7d0c3baff13724 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 28 Jul 2026 12:49:51 -0300 Subject: [PATCH] fix(work-orders): persist completion doc fields without race Map docStatus through the real update payload, serialize concurrent table patches per work order, and split CompDoc UI for governance. --- scripts/governance-check.mjs | 5 +- .../comp-doc/comp-doc-details-section.tsx | 65 ++++ .../comp-doc/comp-doc-dialog-footer-draft.tsx | 50 +++ .../comp-doc-dialog-footer-generated.tsx | 35 ++ .../comp-doc-dialog-footer-not-required.tsx | 47 +++ .../comp-doc/comp-doc-dialog-footer.tsx | 47 +++ .../comp-doc/comp-doc-dialog-header.tsx | 94 +++++ .../_components/comp-doc/comp-doc-dialog.tsx | 338 ++---------------- .../comp-doc/comp-doc-draft-toast.tsx | 29 ++ .../comp-doc/comp-doc-editable-row.tsx | 54 +++ .../comp-doc/comp-doc-footer-btn-sx.ts | 8 + .../comp-doc/comp-doc-not-required-stamp.tsx | 25 ++ .../comp-doc/comp-doc-notes-field.tsx | 50 +++ .../comp-doc/comp-doc-preview-badge.tsx | 32 ++ .../comp-doc/comp-doc-preview-header.tsx | 36 ++ .../comp-doc/comp-doc-preview-procedure.tsx | 87 +++++ .../comp-doc/comp-doc-preview-summary.tsx | 96 +++++ .../_components/comp-doc/comp-doc-preview.tsx | 234 +----------- .../_components/comp-doc/comp-doc-sidebar.tsx | 181 +--------- .../comp-doc/comp-doc-status-card.tsx | 61 ++++ .../workorders/_hooks/use-comp-doc-dialog.ts | 129 +++++++ src/app/(protected)/workorders/index.tsx | 5 + .../mappers/wizard-draft-mapper.ts | 1 + .../work-orders/mappers/work-order-mapper.ts | 4 + .../work-orders/mocks/wo-table-mock-data.ts | 1 + .../work-orders/mocks/wo-wizard-mock-data.ts | 1 + .../work-orders/types/work-order-detail.ts | 6 + .../use-work-order-table-mutations.ts | 101 +++--- .../utils/work-order-patch-queue.ts | 20 ++ .../mappers/work-order-form-payload.test.ts | 14 + .../mappers/work-order-mapper.test.ts | 23 ++ .../use-work-order-table-mutations.test.tsx | 156 ++++++++ .../utils/work-order-patch-queue.test.ts | 62 ++++ 33 files changed, 1360 insertions(+), 737 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.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-draft-toast.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-summary.tsx create mode 100644 src/app/(protected)/workorders/_components/comp-doc/comp-doc-status-card.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts create mode 100644 src/domain/work-orders/utils/work-order-patch-queue.ts create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-patch-queue.test.ts diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 41bb1de9..9e388ef4 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -164,11 +164,12 @@ function maintainabilityGate(files) { if (files.length === 0) { return { skipped: true, reason: "no changed governed TS/TSX files" }; } - const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint"); + const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js"); const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]); const result = spawnSync( - eslintBin, + process.execPath, [ + eslintJs, ...files, ...ruleArgs, "--max-warnings=0", diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx new file mode 100644 index 00000000..a1ed784d --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx @@ -0,0 +1,65 @@ +import { Building2, MapPin, Users, Wrench } from "lucide-react"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocDetailRow } from "./comp-doc-detail-row"; +import { CompDocEditableRow } from "./comp-doc-editable-row"; + +type CompDocDetailsSectionProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + editDateCompleted: string; + onDateCompletedChange: (value: string) => void; + amazonRep: string; + seaRep: string; + isGenerated: boolean; +}; + +export function CompDocDetailsSection({ + row, + site, + editDateCompleted, + onDateCompletedChange, + amazonRep, + seaRep, + isGenerated, +}: CompDocDetailsSectionProps) { + return ( +
+
+ Work Order Details +
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx new file mode 100644 index 00000000..fad1529f --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx @@ -0,0 +1,50 @@ +import { Ban, Save, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterDraftProps = { + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraft({ + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftProps) { + return ( + <> + +
+ + +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx new file mode 100644 index 00000000..91b9792c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx @@ -0,0 +1,35 @@ +import { CheckCircle2, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterGeneratedProps = { + generatedAt: string; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGenerated({ + generatedAt, + onDownloadPdf, +}: CompDocDialogFooterGeneratedProps) { + return ( + <> + + Generated {generatedAt} + +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx new file mode 100644 index 00000000..da1976bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx @@ -0,0 +1,47 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterNotRequiredProps = { + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
+ + +
+ + ); +} 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..4e35987c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx @@ -0,0 +1,47 @@ +import type { DocState } from "@/domain/work-orders/types/completion-doc"; +import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft"; +import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated"; +import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required"; + +type CompDocDialogFooterProps = { + docState: DocState; + generatedAt: string; + onMarkNotRequired: () => void; + onUndo: () => void; + onSaveDraft: () => void; + onGenerate: () => void; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooter({ + docState, + generatedAt, + onMarkNotRequired, + onUndo, + onSaveDraft, + onGenerate, + onDownloadPdf, +}: CompDocDialogFooterProps) { + return ( +
+ {docState === "draft" && ( + + )} + {docState === "generated" && ( + + )} + {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..d014c7e4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -0,0 +1,94 @@ +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 = { + row: WorkOrderTableRow; + cfg: CompletionDocStateConfig; + onClose: () => void; +}; + +export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) { + return ( +
+
+ +
+
+ Completion Document +
+
+ WO{" "} + + #{row.woNumber} + + {" · "} + {serviceTitle(row)} +
+
+
+
+ + + {cfg.pillLabel} + + +
+
+ ); +} 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 833f9047..6415f3fd 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,39 +1,15 @@ -import { useEffect, useMemo, 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, 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 { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data"; -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 { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog"; +import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; +import { CompDocDialogHeader } from "./comp-doc-dialog-header"; import { CompDocPreview } from "./comp-doc-preview"; import { CompDocSidebar } from "./comp-doc-sidebar"; -const FOOTER_BTN_SX = { - minHeight: 28, - height: 28, - fontSize: 12, - px: 1.25, - py: 0.5, - gap: 0.75, -} as const; - type CompDocDialogProps = { row: WorkOrderTableRow; open: boolean; @@ -51,102 +27,25 @@ export function CompDocDialog({ onSave, onDateCompletedChange, }: CompDocDialogProps) { - const locId = String(row.locationId || ""); - const isMockLocation = locId.startsWith("mock-"); - const { data: apiLocationDetail } = useLocationDetail( - isMockLocation || !locId ? undefined : locId, - ); - const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail; - - const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, 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(row.docStatus)); - setGeneratedAt(data?.generatedAt ?? ""); - setEditDateCompleted(row.completedDate || ""); - }, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]); - - const amazonRep = row.pocName || ""; - const seaRep = row.tech || ""; - const cfg = DOC_STATE_CFG[docState]; - const isNotRequired = docState === "not-required"; - const isGenerated = docState === "generated"; - - const docFields: DocFields = { - street: site.street, - city: site.city, - state: site.state, - zip: site.zip, - dateCompleted: editDateCompleted, + const { + site, + notes, + setNotes, + docState, + generatedAt, + editDateCompleted, + setEditDateCompleted, 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 !== (row.completedDate || "")) { - 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 !== (row.completedDate || "")) { - onDateCompletedChange?.(editDateCompleted); - } - } - - function handleDownloadPdf() { - printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); - } + cfg, + isNotRequired, + isGenerated, + handleMarkNotRequired, + handleUndo, + handleSaveDraft, + handleGenerate, + handleDownloadPdf, + } = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange }); return (
-
-
- -
-
- Completion Document -
-
- WO{" "} - - #{row.woNumber} - - {" · "} - {serviceTitle(row)} -
-
-
-
- - - {cfg.pillLabel} - - -
-
+ onOpenChange(false)} />
-
- {docState === "draft" && ( - <> - -
- - -
- - )} - {docState === "generated" && ( - <> - - Generated {generatedAt} - -
- -
- - )} - {docState === "not-required" && ( - <> - - Marked{" "} - not required for this work order - -
- - -
- - )} -
+
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx new file mode 100644 index 00000000..910b7227 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx @@ -0,0 +1,29 @@ +import { CheckCircle2 } from "lucide-react"; +import { toast } from "react-toastify"; + +export function showCompDocDraftSavedToast() { + toast( +
+ Draft saved +
, + { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + closeButton: false, + icon: false, + className: "!min-h-0 !bg-transparent !p-0 !shadow-none", + }, + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx new file mode 100644 index 00000000..8e9261e2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx @@ -0,0 +1,54 @@ +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)", +}; + +const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = { + opacity: 0.6, + cursor: "default", + pointerEvents: "none", +}; + +type CompDocEditableRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + placeholder?: string; + disabled?: boolean; +}; + +export function CompDocEditableRow({ + label, + value, + onChange, + type = "text", + placeholder = "", + disabled = false, +}: CompDocEditableRowProps) { + return ( +
+
+ {label} +
+ onChange(e.target.value)} + readOnly={disabled} + placeholder={placeholder} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}), + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-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-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx new file mode 100644 index 00000000..ab3cbf04 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx @@ -0,0 +1,25 @@ +import { Ban } from "lucide-react"; + +export function CompDocNotRequiredStamp() { + return ( +
+
+ NOT REQUIRED +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx new file mode 100644 index 00000000..f6deb5e5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx @@ -0,0 +1,50 @@ +type CompDocNotesFieldProps = { + notes: string; + onNotesChange: (value: string) => void; + isGenerated: boolean; +}; + +export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) { + return ( +
+ +