Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-25 05:46:43 +03:00
14 changed files with 746 additions and 59 deletions

View File

@@ -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({
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : nextAction ? (
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (
<Group

View File

@@ -0,0 +1,186 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Send, XCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
import { ResubmitDocuments } from "@/pages/bookings/resubmit/ResubmitDocuments";
import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { BodyGrid } from "./components/layout";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
* Unlike a fresh draft, this booking already went through submission, so the
* documents shown are exactly the files the customer submitted (`booking.files`)
* — not a fixed required-document list. The customer reviews the staff note,
* replaces any document they need to update, and resubmits in place.
*/
export function ChangesRequestedView({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated: () => 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 (
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>
</Group>
<Text fz="12.5px" c="#6B7C8E" mb="sm">
These are the documents you submitted for this booking. Replace
any you need to update, then resubmit for review.
</Text>
<ResubmitDocuments flow={flow} />
<Button
fullWidth
mt="lg"
radius={10}
color="#0C1A2B"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
disabled={flow.isBusy}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{flow.isBusy ? "Resubmitting…" : "Resubmit for review"}
</Button>
</SectionCard>
</>
}
right={
<>
<EstimateCard
pricing={pricing}
title="Estimated Cost"
chip="Not invoiced"
/>
<ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
</>
}
/>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Cancel booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to cancel <strong>{booking.reference}</strong>?
This action cannot be undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans, duplicate booking…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius="md"
onClick={() =>
cancelMutation.mutate(cancelReason.trim() || "Cancelled by customer")
}
disabled={cancelMutation.isPending}
loading={cancelMutation.isPending}
leftSection={
!cancelMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, cancel
</Button>
</Group>
</Stack>
</Modal>
</PageShell>
);
}

View File

@@ -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<string, File | File[] | null>) => {
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<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -190,19 +175,7 @@ export function DraftBookingView({
]}
/>
<StatusHero booking={booking}>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<StatusHero booking={booking} />
<BodyGrid
left={
@@ -294,8 +267,9 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => 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 (
<DocRow
key={doc.key}

View File

@@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import { api } from "@/services/api";
import { ChangesRequestedView } from "./ChangesRequestedView";
import { DraftBookingView } from "./DraftBookingView";
import { PageShell, SectionCard } from "./components/layout";
import { ReadonlyBookingView } from "./ReadonlyBookingView";
@@ -77,6 +78,17 @@ export default function BookingDetailPage() {
);
}
// Staff returned the booking for changes: resubmit-with-updated-documents
// flow, driven by the files the customer actually submitted.
if (booking.status === "CHANGES_REQUESTED") {
return (
<ChangesRequestedView
booking={booking}
onBookingUpdated={refetchBooking}
/>
);
}
// Brand-new draft: collect the required documents before first submit.
if (isDraftLike(booking.status)) {
return (
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />

View File

@@ -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({
</Button>
);
}
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Review changes
</Button>
);
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"

View File

@@ -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}
</Button>
<BookingActionModal booking={booking} opened={opened} onClose={close} />
{isChangesRequested ? (
<ResubmitBookingModal
booking={booking}
opened={opened}
onClose={close}
/>
) : (
<BookingActionModal booking={booking} opened={opened} onClose={close} />
)}
</>
);
}

View File

@@ -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";

View File

@@ -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<Freight.IBooking, "status">,
): boolean {
return (
booking.status === "CHANGES_REQUESTED" ||
getBookingNextAction(booking) !== null
);
}

View File

@@ -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 (
<Modal
opened={data !== null}
onClose={onClose}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{data && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{data.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{data.previousTotalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{data.totalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
{data.lineItems && data.lineItems.length > 0 && (
<Stack gap={4}>
{data.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmPending}
onClick={onConfirm}
>
Confirm &amp; submit
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -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 <ResubmitBookingModalBody booking={booking} onClose={onClose} />;
}
function ResubmitBookingModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const flow = useResubmitFlow(booking, { onResubmitted: onClose });
return (
<>
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
Update &amp; resubmit
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{booking.reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
<Stack gap="md">
{booking.latestChangeRequestNote && (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={18} />}
title="Changes requested by EDR"
>
{booking.latestChangeRequestNote}
</Alert>
)}
<Box>
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Your documents
</Text>
<Text fz="12px" c="dimmed" mb="xs">
Replace any document you need to update, then resubmit.
</Text>
<ResubmitDocuments flow={flow} />
</Box>
{flow.mutations.some((m) => m.isError) && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Something went wrong. Please try again.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
>
Resubmit booking
</Button>
</Group>
</Stack>
</Modal>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
</>
);
}

View File

@@ -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<Record<string, HTMLInputElement | null>>({});
if (rows.length === 0) {
return (
<Text fz="13px" c="dimmed" py="sm">
No documents were submitted on this booking.
</Text>
);
}
return (
<Box>
{rows.map((row, i) => {
const replaced = replacements[row.key];
return (
<DocRow
key={row.key}
last={i === rows.length - 1}
title={row.label}
meta={replaced ? replaced.name : (row.file.name ?? "Submitted")}
status={replaced ? "ready" : "verified"}
action={
<>
<IconSquare
href={row.file.signedUrl ?? row.file.url}
icon={<Download size={16} />}
/>
<input
ref={(el) => {
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)
}
/>
<Group gap={6} wrap="nowrap">
<Button
variant="white"
radius={9}
leftSection={<Upload size={16} color="#334155" />}
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"}
</Button>
{replaced && (
<ActionIcon
variant="default"
radius={8}
w={34}
h={34}
onClick={() => setReplacement(row.key, null)}
style={{ color: "#C0392B" }}
>
<X size={15} />
</ActionIcon>
)}
</Group>
</>
}
/>
);
})}
</Box>
);
}

View File

@@ -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";

View File

@@ -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<Freight.IBooking["files"]>[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<Freight.IBooking, "files">,
): ResubmitDocRow[] {
const files = booking.files ?? [];
const order: string[] = [];
const latestByCode = new Map<string, BookingFile>();
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 };
});
}

View File

@@ -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<Record<string, File | null>>(
{},
);
const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
null,
);
const hasReplacements = Object.values(replacements).some(Boolean);
const invalidateLists = () =>
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
const updateMutation = useMutation({
mutationFn: (files: Record<string, File | null>) =>
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<string, File | null> = {};
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<typeof useResubmitFlow>;