mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user