From 76a45ac461201509c00d3f1b37d85577dcfef7db Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 02:19:32 +0000 Subject: [PATCH] feat: implement clearance flow for booking operations - Update booking form schema to remove customs clearing agent requirement. - Add BookingActionButton component to trigger actions for bookings. - Create BookingActionModal for handling document uploads and operation scheduling. - Implement ClearanceFlow component to manage document statuses and uploads. - Add OperationDatePicker for selecting shipment days. - Introduce bookingNextAction utility to determine next actions based on booking status. - Create useClearanceFlow hook to encapsulate clearance logic and state management. - Export new components and utilities from the clearance module. --- .../MyPortalPage/components/BookingRow.tsx | 6 + .../src/pages/MyPortalPage/constants.ts | 55 ++ .../components/ClearanceCard.tsx | 635 ++---------------- .../clearance/BookingActionButton.tsx | 67 ++ .../bookings/clearance/BookingActionModal.tsx | 112 +++ .../bookings/clearance/ClearanceFlow.tsx | 304 +++++++++ .../clearance/OperationDatePicker.tsx | 219 ++++++ .../bookings/clearance/bookingNextAction.ts | 53 ++ .../src/pages/bookings/clearance/index.ts | 14 + .../bookings/clearance/useClearanceFlow.ts | 139 ++++ .../pages/bookings/new-booking-form/schema.ts | 12 +- 11 files changed, 1022 insertions(+), 594 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index 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) => ( - - )} - - )} - - - {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 && ( + + Submit documents + + )} + {flow.isReady && ( + + )} - - {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 && ( - - )} - {isReady && ( - - )} - + } + /> ); } - -/** - * 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 ( + <> + + + + + ); +} 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 && ( + + )} + {flow.isReady && ( + + )} + + } + /> + )} + + ); +} 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) => ( + + )} + + )} + + + {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 + + + + + {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({