From 9353d575852d2649c938fac7a3799f18afab7e86 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 02:46:06 +0000 Subject: [PATCH] feat: implement resubmit flow for CHANGES_REQUESTED bookings with document updates --- .../MyPortalPage/components/BookingRow.tsx | 9 +- .../ChangesRequestedView.tsx | 186 ++++++++++++++++++ .../BookingDetailPage/DraftBookingView.tsx | 42 +--- .../bookings/BookingDetailPage/index.tsx | 12 ++ .../portal/src/pages/bookings/MyBookings.tsx | 20 +- .../clearance/BookingActionButton.tsx | 27 ++- .../bookings/clearance/BookingActionModal.tsx | 2 +- .../bookings/clearance/bookingNextAction.ts | 15 ++ .../bookings/resubmit/PriceChangeModal.tsx | 84 ++++++++ .../resubmit/ResubmitBookingModal.tsx | 116 +++++++++++ .../bookings/resubmit/ResubmitDocuments.tsx | 97 +++++++++ .../src/pages/bookings/resubmit/index.ts | 13 ++ .../pages/bookings/resubmit/resubmitDocs.ts | 63 ++++++ .../bookings/resubmit/useResubmitFlow.ts | 119 +++++++++++ 14 files changed, 746 insertions(+), 59 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index 071253899..54bc644ca 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -4,7 +4,7 @@ import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; import { Stepper } from "./Stepper"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton"; -import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; +import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction"; interface BookingRowProps { booking: any; @@ -25,8 +25,9 @@ export const BookingRow = memo(function BookingRow({ // instead of navigating to the detail page. const canPay = booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID"; - // Clearance/operation steps the customer can act on in place via a modal. - const nextAction = getBookingNextAction(booking); + // Clearance/operation steps + changes-requested resubmit can be done in place + // via a modal on the row. + const hasInlineAction = bookingHasInlineAction(booking); const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; @@ -89,7 +90,7 @@ export const BookingRow = memo(function BookingRow({ {canPay ? ( - ) : nextAction ? ( + ) : hasInlineAction ? ( ) : ( void; +}) { + const navigate = useNavigate(); + const flow = useResubmitFlow(booking, { onResubmitted: onBookingUpdated }); + + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + + const { data: generatedPricing } = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !booking.pricingBreakdown, + }), + ); + const pricing = (booking.pricingBreakdown ?? + generatedPricing ?? + null) as Freight.PricingBreakdown | null; + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + setCancelDialogOpen(false); + onBookingUpdated(); + }, + }); + + return ( + + setCancelDialogOpen(true), + onSupport: () => navigate("/support"), + }} + /> + + + + + {booking.latestChangeRequestNote ? ( + + {booking.latestChangeRequestNote} + + ) : undefined} + + + + + + + + Your documents + + + These are the documents you submitted for this booking. Replace + any you need to update, then resubmit for review. + + + + + + + + } + right={ + <> + + + setCancelDialogOpen(true)} /> + + } + /> + + + + setCancelDialogOpen(false)} + title={Cancel booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference}? + This action cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 351ff6f5f..1ba37fb85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -31,11 +31,7 @@ import { CardTitle, PageShell, SectionCard } from "./components/layout"; import { CountChip, DocRow, IconSquare } from "./components/Documents"; import { EstimateCard } from "./components/pricing"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { - ActionRequiredBanner, - MutationErrors, - NoticeBanner, -} from "./components/Notices"; +import { MutationErrors, NoticeBanner } from "./components/Notices"; import { ScheduleCard } from "./components/ScheduleCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { StatusHero } from "./components/StatusHero"; @@ -77,9 +73,7 @@ export function DraftBookingView({ const { data: generatedPricing } = useQuery( api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, - enabled: - (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") && - !booking.pricingBreakdown, + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, }), ); const pricing = (booking.pricingBreakdown ?? @@ -87,17 +81,8 @@ export function DraftBookingView({ null) as Freight.PricingBreakdown | null; const uploadMutation = useMutation({ - mutationFn: async (files: Record) => { - if (booking.status === "CHANGES_REQUESTED") { - const result = await api.bookings.update.call({ - id: booking.id, - dto: {}, - documents: files, - }); - return result.booking; - } - return api.bookings.uploadDocuments.call({ id: booking.id, files }); - }, + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), onSuccess: () => { setSelectedFiles({}); setDocError(""); @@ -190,19 +175,7 @@ export function DraftBookingView({ ]} /> - - {booking.status === "CHANGES_REQUESTED" && - booking.latestChangeRequestNote ? ( - - navigate(`/bookings/${booking.id}/edit?section=documents`) - } - > - {booking.latestChangeRequestNote} - - ) : undefined} - + f.code === doc.key); - const allowReplace = - !isUploaded || booking.status === "CHANGES_REQUESTED"; + // In a draft, an already-uploaded doc can still be replaced + // before first submit. + const allowReplace = !isUploaded; return ( + ); + } + // Brand-new draft: collect the required documents before first submit. if (isDraftLike(booking.status)) { return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 20eccbc31..e793e258b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -34,6 +34,8 @@ import { import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { PayNowButton } from "./payments/PayNowButton"; +import { BookingActionButton } from "./clearance/BookingActionButton"; +import { bookingHasInlineAction } from "./clearance/bookingNextAction"; import useAuth from "@/hooks/useAuth"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import { @@ -199,20 +201,10 @@ function PrimaryAction({ ); } - if (status === "CHANGES_REQUESTED") { - return ( - - ); + // CHANGES_REQUESTED + clearance/operation steps are handled in place by a + // modal (update & resubmit, upload clearance docs, schedule & proceed). + if (bookingHasInlineAction(booking)) { + return ; } const payableStatus = isGeneralContract ? "FULLY_EXECUTED" diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index 450fefcdc..0a6fa7d87 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -1,9 +1,11 @@ import { Button } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { AlertCircle, ArrowRight, Upload } from "lucide-react"; +import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react"; import type { Freight } from "@edr/types"; +import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal"; + import { BookingActionModal } from "./BookingActionModal"; import { type BookingActionKind, @@ -36,12 +38,17 @@ export function BookingActionButton({ booking, size = "sm", }: BookingActionButtonProps) { - const action = getBookingNextAction(booking); const [opened, { open, close }] = useDisclosure(false); - if (!action) return null; + // Staff returned the booking for changes — let the customer update the docs + // they submitted and resubmit, in place. + const isChangesRequested = booking.status === "CHANGES_REQUESTED"; + const action = isChangesRequested ? null : getBookingNextAction(booking); - const Icon = ICON_BY_KIND[action.kind]; + if (!isChangesRequested && !action) return null; + + const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; + const label = action ? action.label : "Update & resubmit"; return ( <> @@ -58,10 +65,18 @@ export function BookingActionButton({ open(); }} > - {action.label} + {label} - + {isChangesRequested ? ( + + ) : ( + + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index 9c525c0ec..a0e5c9d4f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Modal, Text } from "@mantine/core"; import { CheckCircle2, Upload } from "lucide-react"; import type { Freight } from "@edr/types"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index c6c26152b..5f88349dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -51,3 +51,18 @@ export function getBookingNextAction( ): BookingNextAction | null { return ACTION_BY_STATUS[booking.status as string] ?? null; } + +/** + * Whether a booking has an in-place action the customer can take from a list + * row via a modal — either a clearance/operation step, or a CHANGES_REQUESTED + * booking that needs documents updated and resubmitting. Used to decide whether + * to render {@link BookingActionButton}. + */ +export function bookingHasInlineAction( + booking: Pick, +): boolean { + return ( + booking.status === "CHANGES_REQUESTED" || + getBookingNextAction(booking) !== null + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx new file mode 100644 index 000000000..94563bd5a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx @@ -0,0 +1,84 @@ +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; + +import type { SubmitBookingResponse } from "@/services/bookings.service"; + +interface PriceChangeModalProps { + data: SubmitBookingResponse | null; + onClose: () => void; + onConfirm: () => void; + confirmPending: boolean; +} + +/** + * Shown when submitting a booking returns a changed price: the customer must + * confirm the new total before the submit completes. Shared by the detail-page + * resubmit flow and the home-page resubmit modal. + */ +export function PriceChangeModal({ + data, + onClose, + onConfirm, + confirmPending, +}: PriceChangeModalProps) { + return ( + Price has changed} + radius="lg" + centered + > + {data && ( + + + {data.message ?? + "The booking price has been updated. Confirm to submit with the new total."} + + {data.previousTotalAmount !== undefined && ( + + + Previous total + + + {data.previousTotalAmount.toLocaleString()} {data.currency} + + + )} + + New total + + {data.totalAmount.toLocaleString()} {data.currency} + + + {data.lineItems && data.lineItems.length > 0 && ( + + {data.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + )} + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx new file mode 100644 index 000000000..1481440c9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx @@ -0,0 +1,116 @@ +import { Alert, Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { AlertCircle, MessageSquareWarning, Send } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { PriceChangeModal } from "./PriceChangeModal"; +import { ResubmitDocuments } from "./ResubmitDocuments"; +import { useResubmitFlow } from "./useResubmitFlow"; + +interface ResubmitBookingModalProps { + booking: Freight.IBooking; + opened: boolean; + onClose: () => void; +} + +/** + * Home-page modal for a CHANGES_REQUESTED booking: shows the staff change + * request, lets the customer update the documents they submitted, and resubmit + * for review — all without leaving the My Shipments list. + * + * Mounted only while `opened` so replacement state resets on each open. + */ +export function ResubmitBookingModal({ + booking, + opened, + onClose, +}: ResubmitBookingModalProps) { + if (!opened) return null; + return ; +} + +function ResubmitBookingModalBody({ + booking, + onClose, +}: { + booking: Freight.IBooking; + onClose: () => void; +}) { + const flow = useResubmitFlow(booking, { onResubmitted: onClose }); + + return ( + <> + + + Update & resubmit + + + {booking.reference} + + + } + overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + styles={{ body: { paddingTop: 8 } }} + > + + {booking.latestChangeRequestNote && ( + } + title="Changes requested by EDR" + > + {booking.latestChangeRequestNote} + + )} + + + + Your documents + + + Replace any document you need to update, then resubmit. + + + + + {flow.mutations.some((m) => m.isError) && ( + }> + Something went wrong. Please try again. + + )} + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx new file mode 100644 index 000000000..745c0767c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx @@ -0,0 +1,97 @@ +import { ActionIcon, Box, Button, Group, Text } from "@mantine/core"; +import { Download, Upload, X } from "lucide-react"; +import { useRef } from "react"; + +import { DocRow, IconSquare } from "../BookingDetailPage/components/Documents"; +import type { ResubmitFlowController } from "./useResubmitFlow"; + +/** + * Document list for resubmitting a CHANGES_REQUESTED booking. Renders exactly + * the files the customer originally submitted (`booking.files`, surfaced through + * the flow controller) and lets them replace any of them before resubmitting. + * + * No fixed required list and no "X of N" gate — the customer updates what they + * actually provided. + */ +export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) { + const { rows, replacements, setReplacement } = flow; + const inputRefs = useRef>({}); + + if (rows.length === 0) { + return ( + + No documents were submitted on this booking. + + ); + } + + return ( + + {rows.map((row, i) => { + const replaced = replacements[row.key]; + return ( + + } + /> + { + inputRefs.current[row.key] = el; + }} + type="file" + accept=".pdf,.jpg,.jpeg,.png" + style={{ display: "none" }} + onChange={(e) => + setReplacement(row.key, e.target.files?.[0] ?? null) + } + /> + + + {replaced && ( + setReplacement(row.key, null)} + style={{ color: "#C0392B" }} + > + + + )} + + + } + /> + ); + })} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts new file mode 100644 index 000000000..da5e67fcb --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts @@ -0,0 +1,13 @@ +export { PriceChangeModal } from "./PriceChangeModal"; +export { ResubmitBookingModal } from "./ResubmitBookingModal"; +export { ResubmitDocuments } from "./ResubmitDocuments"; +export { + getResubmitDocRows, + labelForDocCode, + type BookingFile, + type ResubmitDocRow, +} from "./resubmitDocs"; +export { + useResubmitFlow, + type ResubmitFlowController, +} from "./useResubmitFlow"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts new file mode 100644 index 000000000..f4096ddb9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -0,0 +1,63 @@ +import type { Freight } from "@edr/types"; + +import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants"; + +/** A single uploaded file on a booking. */ +export type BookingFile = NonNullable[number]; + +/** A document the customer can replace when resubmitting after changes. */ +export interface ResubmitDocRow { + /** Stable file code used as the multipart field name on update. */ + key: string; + /** Human-readable label shown in the row. */ + label: string; + /** The currently-submitted file for this row. */ + file: BookingFile; +} + +const LABEL_BY_CODE = new Map( + REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label]), +); + +/** + * Turn a file `code` (e.g. "commercial_invoice", "custom_172..._0") into a + * human label. Known booking-document codes use their configured label; ad-hoc + * / unknown codes are title-cased from the code itself. + */ +export function labelForDocCode(code: string): string { + const known = LABEL_BY_CODE.get(code); + if (known) return known; + return code + .replace(/^custom_\d+_\d+$/, "Additional document") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Documents to show when a customer is updating a booking that staff returned + * with `CHANGES_REQUESTED`. These are exactly the files the customer submitted + * during the booking process (`booking.files`) — not a fixed required list — so + * the customer updates what they actually provided and resubmits. + * + * The API appends a new file record on every (re)upload without removing the + * old one, so `booking.files` can hold several rows for the same `code`. We show + * one row per code using the most recent upload (the last occurrence in the + * array) while preserving the original first-seen order for stable rendering. + */ +export function getResubmitDocRows( + booking: Pick, +): ResubmitDocRow[] { + const files = booking.files ?? []; + + const order: string[] = []; + const latestByCode = new Map(); + for (const file of files) { + if (!latestByCode.has(file.code)) order.push(file.code); + latestByCode.set(file.code, file); // last write wins → most recent upload + } + + return order.map((code) => { + const file = latestByCode.get(code)!; + return { key: code, label: labelForDocCode(code), file }; + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts new file mode 100644 index 000000000..5b2170c54 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts @@ -0,0 +1,119 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import type { SubmitBookingResponse } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; + +import { getResubmitDocRows } from "./resubmitDocs"; + +/** + * Drives the "update documents and resubmit" flow for a booking that staff + * returned with `CHANGES_REQUESTED`. The document set is the files the customer + * actually submitted (`booking.files`); they may replace any of them, then + * resubmit. Shared by the booking detail page and the home-page modal. + */ +export function useResubmitFlow( + booking: Freight.IBooking, + opts?: { onResubmitted?: () => void }, +) { + const queryClient = useQueryClient(); + + const rows = useMemo(() => getResubmitDocRows(booking), [booking]); + + // Replacement files keyed by document code; only changed docs are sent. + const [replacements, setReplacements] = useState>( + {}, + ); + const [priceChange, setPriceChange] = useState( + null, + ); + + const hasReplacements = Object.values(replacements).some(Boolean); + + const invalidateLists = () => + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + + const updateMutation = useMutation({ + mutationFn: (files: Record) => + api.bookings.update.call({ id: booking.id, dto: {}, documents: files }), + onSuccess: () => { + setReplacements({}); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: booking.id }), + }); + invalidateLists(); + opts?.onResubmitted?.(); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: (result) => { + if (result.priceChanged) { + setPriceChange(result); + return; + } + finishResubmit(); + }, + }); + + const confirmSubmitMutation = useMutation({ + mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }), + onSuccess: () => { + setPriceChange(null); + finishResubmit(); + }, + }); + + function finishResubmit() { + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: booking.id }), + }); + invalidateLists(); + opts?.onResubmitted?.(); + } + + const setReplacement = (key: string, file: File | null) => + setReplacements((prev) => ({ ...prev, [key]: file })); + + /** + * Upload any replaced documents (if any), then resubmit the booking for + * review. Replacing files is optional — staff may have asked for a non-doc + * change — so an empty replacement set still submits. + */ + function resubmit() { + const changed: Record = {}; + for (const [key, file] of Object.entries(replacements)) { + if (file) changed[key] = file; + } + if (Object.keys(changed).length > 0) { + updateMutation.mutate(changed, { + onSuccess: () => submitMutation.mutate(), + }); + } else { + submitMutation.mutate(); + } + } + + const isBusy = + updateMutation.isPending || + submitMutation.isPending || + confirmSubmitMutation.isPending; + + return { + rows, + replacements, + hasReplacements, + setReplacement, + resubmit, + isBusy, + priceChange, + clearPriceChange: () => setPriceChange(null), + confirmSubmit: () => confirmSubmitMutation.mutate(), + confirmSubmitPending: confirmSubmitMutation.isPending, + mutations: [updateMutation, submitMutation, confirmSubmitMutation] as const, + }; +} + +export type ResubmitFlowController = ReturnType;