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 ffd59666e..071253899 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
@@ -3,6 +3,8 @@ import { memo } from "react";
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";
interface BookingRowProps {
booking: any;
@@ -23,6 +25,8 @@ 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);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -85,6 +89,8 @@ export const BookingRow = memo(function BookingRow({
{canPay ? (
+ ) : nextAction ? (
+
) : (
= {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
+ AWAITING_DOCUMENTS: {
+ stage: 3,
+ icon: FileUp,
+ iconColor: "edr-amber-text",
+ tile: "edr-amber-soft",
+ hint: "Clearance documents needed",
+ step: "edr-accent",
+ badgeLabel: "Docs needed",
+ badgeBg: "edr-amber-soft",
+ badgeText: "edr-amber-text",
+ badgeDot: "edr-accent",
+ action: { label: "Upload documents", kind: "amber", icon: ArrowRight },
+ },
+ DOCUMENTS_UNDER_REVIEW: {
+ stage: 3,
+ icon: ShieldCheck,
+ iconColor: "edr-blue",
+ tile: "edr-blue-soft",
+ hint: "Clearance under review · re-upload any queried docs",
+ step: "edr-blue-dot",
+ badgeLabel: "In review",
+ badgeBg: "edr-blue-soft",
+ badgeText: "edr-blue",
+ badgeDot: "edr-blue-dot",
+ action: { label: "Review documents", kind: "outline" },
+ },
+ CLEARANCE_READY: {
+ stage: 3,
+ icon: CalendarClock,
+ iconColor: "edr-green.7",
+ tile: "edr-soft",
+ hint: "Cleared · choose a shipment day to proceed",
+ step: "edr-green.5",
+ badgeLabel: "Cleared",
+ badgeBg: "edr-soft",
+ badgeText: "edr-green.7",
+ badgeDot: "edr-green.5",
+ action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight },
+ },
+ OPERATION_REQUESTED: {
+ stage: 3,
+ icon: CheckCircle2,
+ iconColor: "edr-green.7",
+ tile: "edr-soft",
+ hint: "Operation requested · operator taking it forward",
+ step: "edr-green.5",
+ badgeLabel: "Operation requested",
+ badgeBg: "edr-soft",
+ badgeText: "edr-green.7",
+ badgeDot: "edr-green.5",
+ action: { label: "View", kind: "outline" },
+ },
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
index be60bc393..0d8ec8b4e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx
@@ -1,144 +1,28 @@
-import {
- Alert,
- Box,
- Button,
- FileButton,
- Group,
- Stack,
- Text,
- TextInput,
-} from "@mantine/core";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- addMonths,
- eachDayOfInterval,
- endOfMonth,
- endOfWeek,
- format,
- isSameMonth,
- isToday,
- startOfMonth,
- startOfWeek,
-} from "date-fns";
-import {
- AlertCircle,
- Calendar as CalendarIcon,
- Check,
- CheckCircle2,
- ChevronLeft,
- ChevronRight,
- Clock,
- Download,
- FileText,
- Plus,
- Upload,
-} from "lucide-react";
-import { useMemo, useState } from "react";
+import { Alert, Button, Group } from "@mantine/core";
+import { CheckCircle2, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
-import { api } from "@/services/api";
import type { Freight } from "@edr/types";
+import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
+import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
+
import { CardTitle, SectionCard } from "./layout";
-import { IconSquare } from "./Documents";
-
-const GREEN = "#0A6F4D";
-
-function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
- if (doc.reviewStatus === "APPROVED") {
- return (
-
-
-
- Approved
-
-
- );
- }
- if (doc.reviewStatus === "QUERIED") {
- return (
-
-
-
- Queried
-
-
- );
- }
- if (doc.file) {
- return (
-
-
-
- Pending review
-
-
- );
- }
- return (
-
- Not uploaded
-
- );
-}
/**
- * Customer-facing clearance section: shows the resolved document grid, lets the
- * customer (re)upload pending/queried documents plus ad-hoc named documents, and
- * proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
+ * Customer-facing clearance section on the booking detail page: shows the
+ * resolved document grid, lets the customer (re)upload pending/queried documents
+ * plus ad-hoc named documents, and proceed to operation once Global Logistics
+ * marks the booking CLEARANCE_READY.
+ *
+ * The flow body, calendar, and mutations are shared with the home-page action
+ * modal via `useClearanceFlow` / `ClearanceFlow`.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
- const queryClient = useQueryClient();
const navigate = useNavigate();
- const status = booking.status as string;
+ const flow = useClearanceFlow(booking);
- const { data: clearance, isLoading } = useQuery(
- api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
- );
-
- // Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
- const [pending, setPending] = useState>({});
- const [adHoc, setAdHoc] = useState>(
- [],
- );
- // Binding shipment day chosen for the operation request (yyyy-MM-dd).
- const [scheduledDate, setScheduledDate] = useState("");
-
- const refresh = () => {
- queryClient.invalidateQueries({
- queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
- });
- queryClient.invalidateQueries({
- queryKey: api.bookings.get.queryKey({ id: booking.id }),
- });
- };
-
- const uploadMutation = useMutation({
- ...api.bookings.submitClearanceDocuments.mutationOptions(),
- onSuccess: () => {
- setPending({});
- setAdHoc([]);
- refresh();
- },
- });
-
- const proceedMutation = useMutation({
- ...api.bookings.proceedToOperation.mutationOptions(),
- onSuccess: () => refresh(),
- });
-
- // Only the customer-input documents are uploadable here; GL output docs are
- // shown read-only.
- const customerDocs = useMemo(
- () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
- [clearance],
- );
- const glDocs = useMemo(
- () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
- [clearance],
- );
-
- if (status === "OPERATION_REQUESTED") {
+ if (flow.status === "OPERATION_REQUESTED") {
return (
Operation
@@ -149,477 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
);
}
- if (isLoading || !clearance) {
+ if (flow.isLoading || !flow.clearance) {
return (
Clearance documents
-
- Loading clearance…
-
);
}
- const isReady = status === "CLEARANCE_READY";
- const canUpload =
- status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
-
- function handleSubmit() {
- const files: Record = { ...pending };
- adHoc.forEach((row, i) => {
- if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
- });
- if (Object.keys(files).length === 0) return;
- uploadMutation.mutate({ id: booking.id, files });
- }
-
return (
Clearance documents
- {clearance.includesCustoms && (
-
- Customs clearance
-
- )}
- {isReady ? (
- } mb="md">
- {clearance.includesCustoms
- ? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
- : "Clearance is ready. You can now proceed to operation."}
-
- ) : status === "DOCUMENTS_UNDER_REVIEW" ? (
- } mb="md">
- {clearance.includesCustoms
- ? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
- : "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
-
- ) : (
- } mb="md">
- {clearance.includesCustoms
- ? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here."
- : "Upload all the required clearance documents below to start the review."}
-
- )}
-
-
- {customerDocs.map((doc) => (
-
-
-
-
-
-
-
-
- {doc.label}
- {doc.required ? " *" : ""}
-
- {doc.file && (
-
- {doc.file.name}
-
- )}
-
-
-
-
- {doc.file && (
- } />
- )}
- {canUpload && doc.reviewStatus !== "APPROVED" && (
-
- f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
- }
- accept="application/pdf,image/*"
- >
- {(props) => (
- }
- >
- {pending[doc.fileKey] ? "Selected" : "Upload"}
-
- )}
-
- )}
-
-
- {doc.reviewStatus === "QUERIED" && doc.note && (
-
- Query: {doc.note}
-
- )}
- {pending[doc.fileKey] && (
-
- Ready to upload: {pending[doc.fileKey].name}
-
- )}
-
- ))}
-
-
- {/* GL output documents (read-only to the customer). */}
- {glDocs.length > 0 && (
- <>
-
- Customs output documents
-
-
- {glDocs.map((doc) => (
-
+ {flow.canUpload && (
+ }
+ onClick={() => flow.submitDocuments()}
+ loading={flow.uploadMutation.isPending}
+ disabled={!flow.hasStagedFiles}
>
-
- {doc.label}
-
- {doc.file ? (
- } />
- ) : (
-
- Pending
-
- )}
-
- ))}
-
- >
- )}
-
- {/* Ad-hoc / additional documents. */}
- {canUpload && (
-
-
-
- Additional documents
-
- }
- onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
- >
- Add document
-
+ Submit documents
+
+ )}
+ {flow.isReady && (
+ }
+ onClick={() =>
+ flow.proceedToOperation({
+ onSuccess: () => navigate(`/bookings/${booking.id}`),
+ })
+ }
+ loading={flow.proceedMutation.isPending}
+ disabled={!flow.scheduledDate}
+ >
+ Proceed to operation
+
+ )}
-
- {adHoc.map((row, i) => (
-
-
- setAdHoc((rows) =>
- rows.map((r, j) =>
- j === i ? { ...r, name: e.currentTarget.value } : r,
- ),
- )
- }
- style={{ flex: 1 }}
- radius="md"
- />
-
- setAdHoc((rows) =>
- rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
- )
- }
- accept="application/pdf,image/*"
- >
- {(props) => (
-
- )}
-
-
- ))}
-
-
- )}
-
- {uploadMutation.isError && (
- } mt="md">
- {uploadMutation.error instanceof Error
- ? uploadMutation.error.message
- : "Upload failed. Please try again."}
-
- )}
-
- {isReady && (
-
-
- Choose your shipment day
-
-
- Only days with a scheduled departure on your route can be selected.
- The operations team assigns the specific train for that day.
-
-
-
- )}
-
- {proceedMutation.isError && (
- } mt="md">
- {proceedMutation.error instanceof Error
- ? proceedMutation.error.message
- : "Could not request the operation. Please try again."}
-
- )}
-
-
- {canUpload && (
- }
- onClick={handleSubmit}
- loading={uploadMutation.isPending}
- disabled={
- Object.keys(pending).length === 0 &&
- !adHoc.some((r) => r.file)
- }
- >
- Submit documents
-
- )}
- {isReady && (
- }
- onClick={() =>
- proceedMutation.mutate(
- { id: booking.id, scheduledDate },
- { onSuccess: () => navigate(`/bookings/${booking.id}`) },
- )
- }
- loading={proceedMutation.isPending}
- disabled={!scheduledDate}
- >
- Proceed to operation
-
- )}
-
+ }
+ />
);
}
-
-/**
- * Compact month calendar for picking the binding shipment day at the
- * operation-request step. Only days that have an OPEN scheduled departure on the
- * booking route are selectable; all other days are disabled.
- */
-function OperationDatePicker({
- originYardId,
- destinationYardId,
- value,
- onChange,
-}: {
- originYardId?: string;
- destinationYardId?: string;
- value: string;
- onChange: (date: string) => void;
-}) {
- const [month, setMonth] = useState(() => startOfMonth(new Date()));
-
- const { data: availableDays, isLoading } = useQuery(
- api.bookings.getAvailableDays.queryOptions({
- input: { originYardId, destinationYardId },
- enabled: !!originYardId && !!destinationYardId,
- }),
- );
-
- const departureDays = useMemo(
- () => new Set(availableDays ?? []),
- [availableDays],
- );
-
- const cells = useMemo(() => {
- const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
- const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
- return eachDayOfInterval({ start, end }).map((date) => {
- const dateString = format(date, "yyyy-MM-dd");
- return {
- date,
- dateString,
- day: date.getDate(),
- inMonth: isSameMonth(date, month),
- today: isToday(date),
- selected: value === dateString,
- hasDeparture: departureDays.has(dateString),
- };
- });
- }, [month, departureDays, value]);
-
- return (
-
-
-
-
- {format(month, "MMMM yyyy")}
-
-
-
-
- {isLoading ? (
-
-
-
- Loading available days…
-
-
- ) : (
- <>
-
- {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
-
- {d}
-
- ))}
-
-
- {cells.map((c) => {
- const clickable = c.hasDeparture && c.inMonth;
- return (
-
- );
- })}
-
- {value && (
-
- Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
-
- )}
- {!isLoading && departureDays.size === 0 && (
-
- No scheduled departures found for this route yet.
-
- )}
- >
- )}
-
- );
-}
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
new file mode 100644
index 000000000..450fefcdc
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx
@@ -0,0 +1,67 @@
+import { Button } from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import { AlertCircle, ArrowRight, Upload } from "lucide-react";
+
+import type { Freight } from "@edr/types";
+
+import { BookingActionModal } from "./BookingActionModal";
+import {
+ type BookingActionKind,
+ getBookingNextAction,
+} from "./bookingNextAction";
+
+const ICON_BY_KIND: Record<
+ BookingActionKind,
+ typeof Upload
+> = {
+ UPLOAD_DOCUMENTS: Upload,
+ FIX_DOCUMENTS: AlertCircle,
+ SCHEDULE_OPERATION: ArrowRight,
+};
+
+interface BookingActionButtonProps {
+ booking: Freight.IBooking;
+ size?: "xs" | "sm";
+}
+
+/**
+ * Self-contained next-action trigger for a My Shipments row. Renders nothing
+ * when the booking has no customer-actionable clearance/operation step;
+ * otherwise shows a button that opens the in-place {@link BookingActionModal}.
+ *
+ * Drop it into a list row exactly like {@link PayNowButton} — it stops click
+ * propagation so it never triggers the row's navigation handler.
+ */
+export function BookingActionButton({
+ booking,
+ size = "sm",
+}: BookingActionButtonProps) {
+ const action = getBookingNextAction(booking);
+ const [opened, { open, close }] = useDisclosure(false);
+
+ if (!action) return null;
+
+ const Icon = ICON_BY_KIND[action.kind];
+
+ return (
+ <>
+ }
+ onClick={(e) => {
+ // Don't let the surrounding row-click handler fire.
+ e.stopPropagation();
+ open();
+ }}
+ >
+ {action.label}
+
+
+
+ >
+ );
+}
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
new file mode 100644
index 000000000..9c525c0ec
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx
@@ -0,0 +1,112 @@
+import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
+import { CheckCircle2, Upload } from "lucide-react";
+
+import type { Freight } from "@edr/types";
+
+import { ClearanceFlow } from "./ClearanceFlow";
+import { getBookingNextAction } from "./bookingNextAction";
+import { useClearanceFlow } from "./useClearanceFlow";
+
+interface BookingActionModalProps {
+ booking: Freight.IBooking;
+ opened: boolean;
+ onClose: () => void;
+}
+
+/**
+ * Home-page action modal: runs the full clearance / operation flow for a single
+ * booking without leaving the My Shipments list. The customer can upload the
+ * required documents, re-upload queried ones, then pick a shipment day and
+ * proceed to operation — all in place.
+ *
+ * Mounted only while `opened` so the clearance grid is fetched lazily and the
+ * staged-upload state resets every time the customer reopens it.
+ */
+export function BookingActionModal({
+ booking,
+ opened,
+ onClose,
+}: BookingActionModalProps) {
+ if (!opened) return null;
+ return ;
+}
+
+function BookingActionModalBody({
+ booking,
+ onClose,
+}: {
+ booking: Freight.IBooking;
+ onClose: () => void;
+}) {
+ const action = getBookingNextAction(booking);
+ const flow = useClearanceFlow(booking);
+ const reference = booking.reference;
+
+ const handleSubmit = () => flow.submitDocuments();
+ const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
+
+ return (
+
+
+ {action?.title ?? "Booking"}
+
+
+ {reference}
+
+
+ }
+ overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
+ styles={{ body: { paddingTop: 8 } }}
+ >
+ {flow.isLoading || !flow.clearance ? (
+
+ Loading clearance…
+
+ ) : (
+
+
+ {flow.canUpload && (
+ }
+ onClick={handleSubmit}
+ loading={flow.uploadMutation.isPending}
+ disabled={!flow.hasStagedFiles}
+ >
+ Submit documents
+
+ )}
+ {flow.isReady && (
+ }
+ onClick={handleProceed}
+ loading={flow.proceedMutation.isPending}
+ disabled={!flow.scheduledDate}
+ >
+ Proceed to operation
+
+ )}
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
new file mode 100644
index 000000000..5e410ce33
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
@@ -0,0 +1,304 @@
+import {
+ Alert,
+ Box,
+ Button,
+ FileButton,
+ Group,
+ Stack,
+ Text,
+ TextInput,
+} from "@mantine/core";
+import {
+ AlertCircle,
+ CheckCircle2,
+ Clock,
+ Download,
+ FileText,
+ Plus,
+ Upload,
+} from "lucide-react";
+
+import type { Freight } from "@edr/types";
+
+import { IconSquare } from "../BookingDetailPage/components/Documents";
+import { OperationDatePicker } from "./OperationDatePicker";
+import type { ClearanceFlowController } from "./useClearanceFlow";
+
+const GREEN = "#0A6F4D";
+
+function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
+ if (doc.reviewStatus === "APPROVED") {
+ return (
+
+
+
+ Approved
+
+
+ );
+ }
+ if (doc.reviewStatus === "QUERIED") {
+ return (
+
+
+
+ Queried
+
+
+ );
+ }
+ if (doc.file) {
+ return (
+
+
+
+ Pending review
+
+
+ );
+ }
+ return (
+
+ Not uploaded
+
+ );
+}
+
+interface ClearanceFlowProps {
+ booking: Freight.IBooking;
+ flow: ClearanceFlowController;
+ /**
+ * Rendered at the bottom of the flow (the submit / proceed buttons). Host
+ * supplies this so the detail card and the modal can place actions in their
+ * own footer chrome.
+ */
+ footer?: React.ReactNode;
+}
+
+/**
+ * Presentational body of the customer clearance/operation flow: the required
+ * document grid (with re-upload of pending/queried docs), GL output documents,
+ * ad-hoc documents, and the shipment-day picker once CLEARANCE_READY.
+ *
+ * All state lives in the `flow` controller (see `useClearanceFlow`) so this can
+ * be dropped into either the booking detail card or the home-page action modal.
+ */
+export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
+ const {
+ clearance,
+ customerDocs,
+ glDocs,
+ isReady,
+ canUpload,
+ status,
+ pending,
+ adHoc,
+ stagePending,
+ addAdHocRow,
+ setAdHocName,
+ setAdHocFile,
+ scheduledDate,
+ setScheduledDate,
+ uploadMutation,
+ proceedMutation,
+ } = flow;
+
+ if (!clearance) return null;
+
+ return (
+
+ {isReady ? (
+ } mb="md">
+ {clearance.includesCustoms
+ ? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
+ : "Clearance is ready. You can now proceed to operation."}
+
+ ) : status === "DOCUMENTS_UNDER_REVIEW" ? (
+ } mb="md">
+ {clearance.includesCustoms
+ ? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
+ : "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
+
+ ) : (
+ } mb="md">
+ {clearance.includesCustoms
+ ? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here."
+ : "Upload all the required clearance documents below to start the review."}
+
+ )}
+
+
+ {customerDocs.map((doc) => (
+
+
+
+
+
+
+
+
+ {doc.label}
+ {doc.required ? " *" : ""}
+
+ {doc.file && (
+
+ {doc.file.name}
+
+ )}
+
+
+
+
+ {doc.file && (
+ } />
+ )}
+ {canUpload && doc.reviewStatus !== "APPROVED" && (
+ f && stagePending(doc.fileKey, f)}
+ accept="application/pdf,image/*"
+ >
+ {(props) => (
+ }
+ >
+ {pending[doc.fileKey] ? "Selected" : "Upload"}
+
+ )}
+
+ )}
+
+
+ {doc.reviewStatus === "QUERIED" && doc.note && (
+
+ Query: {doc.note}
+
+ )}
+ {pending[doc.fileKey] && (
+
+ Ready to upload: {pending[doc.fileKey].name}
+
+ )}
+
+ ))}
+
+
+ {/* GL output documents (read-only to the customer). */}
+ {glDocs.length > 0 && (
+ <>
+
+ Customs output documents
+
+
+ {glDocs.map((doc) => (
+
+
+ {doc.label}
+
+ {doc.file ? (
+ } />
+ ) : (
+
+ Pending
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+ {/* Ad-hoc / additional documents. */}
+ {canUpload && (
+
+
+
+ Additional documents
+
+ }
+ onClick={addAdHocRow}
+ >
+ Add document
+
+
+
+ {adHoc.map((row, i) => (
+
+ setAdHocName(i, e.currentTarget.value)}
+ style={{ flex: 1 }}
+ radius="md"
+ />
+ setAdHocFile(i, f)}
+ accept="application/pdf,image/*"
+ >
+ {(props) => (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {uploadMutation.isError && (
+ } mt="md">
+ {uploadMutation.error instanceof Error
+ ? uploadMutation.error.message
+ : "Upload failed. Please try again."}
+
+ )}
+
+ {isReady && (
+
+
+ Choose your shipment day
+
+
+ Only days with a scheduled departure on your route can be selected.
+ The operations team assigns the specific train for that day.
+
+
+
+ )}
+
+ {proceedMutation.isError && (
+ } mt="md">
+ {proceedMutation.error instanceof Error
+ ? proceedMutation.error.message
+ : "Could not request the operation. Please try again."}
+
+ )}
+
+ {footer}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx
new file mode 100644
index 000000000..5e5782dfa
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx
@@ -0,0 +1,219 @@
+import { Box, Button, Group, Text } from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import {
+ addMonths,
+ eachDayOfInterval,
+ endOfMonth,
+ endOfWeek,
+ format,
+ isSameMonth,
+ isToday,
+ startOfMonth,
+ startOfWeek,
+} from "date-fns";
+import {
+ Calendar as CalendarIcon,
+ Check,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+
+interface OperationDatePickerProps {
+ originYardId?: string;
+ destinationYardId?: string;
+ value: string;
+ onChange: (date: string) => void;
+}
+
+/**
+ * Compact month calendar for picking the binding shipment day at the
+ * operation-request step. Only days that have an OPEN scheduled departure on the
+ * booking route are selectable; all other days are disabled.
+ *
+ * Shared by the booking detail clearance card and the home-page action modal.
+ */
+export function OperationDatePicker({
+ originYardId,
+ destinationYardId,
+ value,
+ onChange,
+}: OperationDatePickerProps) {
+ const [month, setMonth] = useState(() => startOfMonth(new Date()));
+
+ const { data: availableDays, isLoading } = useQuery(
+ api.bookings.getAvailableDays.queryOptions({
+ input: { originYardId, destinationYardId },
+ enabled: !!originYardId && !!destinationYardId,
+ }),
+ );
+
+ const departureDays = useMemo(
+ () => new Set(availableDays ?? []),
+ [availableDays],
+ );
+
+ const cells = useMemo(() => {
+ const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
+ const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
+ return eachDayOfInterval({ start, end }).map((date) => {
+ const dateString = format(date, "yyyy-MM-dd");
+ return {
+ date,
+ dateString,
+ day: date.getDate(),
+ inMonth: isSameMonth(date, month),
+ today: isToday(date),
+ selected: value === dateString,
+ hasDeparture: departureDays.has(dateString),
+ };
+ });
+ }, [month, departureDays, value]);
+
+ return (
+
+
+
+
+ {format(month, "MMMM yyyy")}
+
+
+
+
+ {isLoading ? (
+
+
+
+ Loading available days…
+
+
+ ) : (
+ <>
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ {cells.map((c) => {
+ const clickable = c.hasDeparture && c.inMonth;
+ return (
+
+ );
+ })}
+
+ {value && (
+
+ Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
+
+ )}
+ {!isLoading && departureDays.size === 0 && (
+
+ No scheduled departures found for this route yet.
+
+ )}
+ >
+ )}
+
+ );
+}
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
new file mode 100644
index 000000000..c6c26152b
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts
@@ -0,0 +1,53 @@
+import type { Freight } from "@edr/types";
+
+/**
+ * The customer-actionable clearance/operation steps a booking can be sitting on.
+ * These are the statuses where the *customer* must do something next — upload
+ * documents, re-upload a queried document, or pick a shipment day and proceed
+ * to operation.
+ */
+export type BookingActionKind =
+ | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
+ | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
+ | "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
+
+export interface BookingNextAction {
+ kind: BookingActionKind;
+ /** Button label shown on the My Shipments row. */
+ label: string;
+ /** Modal title. */
+ title: string;
+}
+
+const ACTION_BY_STATUS: Record = {
+ AWAITING_DOCUMENTS: {
+ kind: "UPLOAD_DOCUMENTS",
+ label: "Upload documents",
+ title: "Upload clearance documents",
+ },
+ DOCUMENTS_UNDER_REVIEW: {
+ kind: "FIX_DOCUMENTS",
+ label: "Review documents",
+ title: "Clearance documents",
+ },
+ CLEARANCE_READY: {
+ kind: "SCHEDULE_OPERATION",
+ label: "Schedule & proceed",
+ title: "Schedule your shipment",
+ },
+};
+
+/**
+ * Resolve the customer's next clearance/operation action for a booking, or
+ * `null` when there's nothing for them to do at this stage. Pure + cheap so it
+ * can be called inline while rendering a list row.
+ *
+ * Note: `DOCUMENTS_UNDER_REVIEW` always surfaces an action because the customer
+ * may need to re-upload a queried document; the modal itself shows a read-only
+ * "under review" state when nothing is actually queried.
+ */
+export function getBookingNextAction(
+ booking: Pick,
+): BookingNextAction | null {
+ return ACTION_BY_STATUS[booking.status as string] ?? null;
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts
new file mode 100644
index 000000000..1f37a72d8
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts
@@ -0,0 +1,14 @@
+export { BookingActionButton } from "./BookingActionButton";
+export { BookingActionModal } from "./BookingActionModal";
+export { ClearanceFlow } from "./ClearanceFlow";
+export { OperationDatePicker } from "./OperationDatePicker";
+export {
+ getBookingNextAction,
+ type BookingActionKind,
+ type BookingNextAction,
+} from "./bookingNextAction";
+export {
+ useClearanceFlow,
+ type AdHocDoc,
+ type ClearanceFlowController,
+} from "./useClearanceFlow";
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts
new file mode 100644
index 000000000..d2d74ae92
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts
@@ -0,0 +1,139 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+import type { Freight } from "@edr/types";
+
+export type AdHocDoc = { name: string; file: File | null };
+
+/**
+ * Encapsulates everything the customer-facing clearance/operation flow needs:
+ * the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
+ * the chosen shipment day, and the submit / proceed mutations.
+ *
+ * Both the booking detail clearance card and the home-page action modal drive
+ * their UI off this single hook so the behaviour stays in lock-step.
+ */
+export function useClearanceFlow(booking: Freight.IBooking) {
+ const queryClient = useQueryClient();
+ const status = booking.status as string;
+
+ const clearanceQuery = useQuery(
+ api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
+ );
+ const clearance = clearanceQuery.data;
+
+ // Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
+ const [pending, setPending] = useState>({});
+ const [adHoc, setAdHoc] = useState([]);
+ // Binding shipment day chosen for the operation request (yyyy-MM-dd).
+ const [scheduledDate, setScheduledDate] = useState("");
+
+ const refresh = () => {
+ queryClient.invalidateQueries({
+ queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
+ });
+ queryClient.invalidateQueries({
+ queryKey: api.bookings.get.queryKey({ id: booking.id }),
+ });
+ queryClient.invalidateQueries({
+ queryKey: api.bookings.list.queryKey(),
+ });
+ };
+
+ const uploadMutation = useMutation({
+ ...api.bookings.submitClearanceDocuments.mutationOptions(),
+ onSuccess: () => {
+ setPending({});
+ setAdHoc([]);
+ refresh();
+ },
+ });
+
+ const proceedMutation = useMutation({
+ ...api.bookings.proceedToOperation.mutationOptions(),
+ onSuccess: () => refresh(),
+ });
+
+ // Only the customer-input documents are uploadable here; GL output docs are
+ // shown read-only.
+ const customerDocs = useMemo(
+ () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
+ [clearance],
+ );
+ const glDocs = useMemo(
+ () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
+ [clearance],
+ );
+
+ const isReady = status === "CLEARANCE_READY";
+ const canUpload =
+ status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
+
+ const hasStagedFiles =
+ Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
+
+ // --- staged-upload mutators ----------------------------------------------
+
+ const stagePending = (fileKey: string, file: File) =>
+ setPending((p) => ({ ...p, [fileKey]: file }));
+
+ const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
+
+ const setAdHocName = (index: number, name: string) =>
+ setAdHoc((rows) =>
+ rows.map((r, j) => (j === index ? { ...r, name } : r)),
+ );
+
+ const setAdHocFile = (index: number, file: File | null) =>
+ setAdHoc((rows) =>
+ rows.map((r, j) => (j === index ? { ...r, file } : r)),
+ );
+
+ // --- actions --------------------------------------------------------------
+
+ const submitDocuments = (opts?: { onSuccess?: () => void }) => {
+ const files: Record = { ...pending };
+ adHoc.forEach((row, i) => {
+ if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
+ });
+ if (Object.keys(files).length === 0) return;
+ uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
+ };
+
+ const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
+ if (!scheduledDate) return;
+ proceedMutation.mutate(
+ { id: booking.id, scheduledDate },
+ { onSuccess: opts?.onSuccess },
+ );
+ };
+
+ return {
+ status,
+ clearance,
+ isLoading: clearanceQuery.isLoading,
+ customerDocs,
+ glDocs,
+ isReady,
+ canUpload,
+ // staged upload state
+ pending,
+ adHoc,
+ hasStagedFiles,
+ stagePending,
+ addAdHocRow,
+ setAdHocName,
+ setAdHocFile,
+ // schedule
+ scheduledDate,
+ setScheduledDate,
+ // mutations
+ uploadMutation,
+ proceedMutation,
+ submitDocuments,
+ proceedToOperation,
+ };
+}
+
+export type ClearanceFlowController = ReturnType;
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
index c4627975a..969c109cc 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
@@ -244,14 +244,10 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
- // Customs clearing agent is required once the customs service is enabled.
- if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
- ctx.addIssue({
- code: "custom",
- path: ["customsClearingAgent"],
- message: "Enter the customs clearing agent.",
- });
- }
+ // The customs clearing agent is only the customer's own broker, named when
+ // the service does NOT bundle customs (EDR/GL handles it otherwise). It is
+ // never required: when the service includes customs the agent is left blank
+ // on purpose, so requiring it would silently block submission.
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({