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.
+
+
+
+
+ }
+ onClick={flow.resubmit}
+ loading={flow.isBusy}
+ disabled={flow.isBusy}
+ styles={{
+ root: { height: 46 },
+ label: { fontSize: 14, fontWeight: 800 },
+ }}
+ >
+ {flow.isBusy ? "Resubmitting…" : "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 (
- }
- onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
- >
- Review changes
-
- );
+ // 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.
+
+ )}
+
+
+
+ }
+ onClick={flow.resubmit}
+ loading={flow.isBusy}
+ >
+ Resubmit booking
+
+
+
+
+
+
+ >
+ );
+}
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)
+ }
+ />
+
+ }
+ onClick={() => inputRefs.current[row.key]?.click()}
+ styles={{
+ root: {
+ height: 34,
+ paddingInline: 13,
+ border: "1.5px solid #CBD5E1",
+ },
+ label: {
+ fontSize: 12.5,
+ fontWeight: 700,
+ color: "#334155",
+ },
+ }}
+ >
+ {replaced ? "Change" : "Replace"}
+
+ {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;