diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx deleted file mode 100644 index db760374f..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ /dev/null @@ -1,1862 +0,0 @@ -import { - Box, - Button, - Center, - Group, - Loader, - Modal, - Stack, - Text, - TextInput, -} from "@mantine/core"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { - AlertCircle, - AlertTriangle, - ArrowDownLeft, - ArrowUpRight, - Check, - CheckCircle2, - ClipboardCheck, - Clock, - CreditCard, - Download, - FileSignature, - FileText, - History, - MessageSquare, - PackageCheck, - Pencil, - Send, - ShieldCheck, - Train, - Upload, - X, - XCircle, -} from "lucide-react"; -import { useMemo, useRef, useState, type ReactNode } from "react"; -import { useNavigate, useParams } from "react-router-dom"; - -import { cn } from "@/lib/utils"; -import { api } from "@/services/api"; -import type { Freight } from "@edr/types"; - -// ─── Constants ─────────────────────────────────────────────────────────────── - -const PROGRESS_STAGES = [ - { - label: "Request", - icon: FileText, - statuses: ["DRAFT", "CHANGES_REQUESTED"], - }, - { - label: "Submitted", - icon: ClipboardCheck, - statuses: ["SUBMITTED", "PENDING_APPROVAL"], - }, - { - label: "Approved", - icon: ShieldCheck, - statuses: [ - "APPROVED_PENDING_SIGNATURE", - "APPROVED", - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - ], - }, - { - label: "In Transit", - icon: Train, - statuses: [ - "PNR_GENERATED", - "PAYMENT_VERIFICATION_IN_PROGRESS", - "PAID", - "IN_TRANSIT", - "PENDING_CONSOLIDATION", - "CONSOLIDATED", - ], - }, - { - label: "Complete", - icon: PackageCheck, - statuses: ["COMPLETED", "DELIVERED"], - }, -]; - -const STATUS_MAP: Record< - string, - { title: string; description: string; stage: number } -> = { - DRAFT: { - title: "Draft — not submitted", - description: - "This booking is being prepared and hasn’t been submitted for review yet.", - stage: 0, - }, - CHANGES_REQUESTED: { - title: "Changes requested", - description: "Staff has requested changes. Please review and resubmit.", - stage: 0, - }, - SUBMITTED: { - title: "Submitted for review", - description: "Your booking has been submitted and is awaiting review.", - stage: 1, - }, - PENDING_APPROVAL: { - title: "Pending approval", - description: "Your booking is moving through the approval process.", - stage: 1, - }, - APPROVED_PENDING_SIGNATURE: { - title: "Approved — awaiting signature", - description: "Approved. Your contract will be ready to sign shortly.", - stage: 2, - }, - APPROVED: { - title: "Approved", - description: "Your booking has been fully approved.", - stage: 2, - }, - CONTRACT_READY: { - title: "Contract ready to sign", - description: - "Your contract is ready. Review and apply your signature to proceed.", - stage: 2, - }, - SIGNED_CUSTOMER: { - title: "Signed — awaiting staff", - description: - "Your signature has been submitted. Awaiting the final staff signature.", - stage: 2, - }, - FULLY_EXECUTED: { - title: "Contract fully executed", - description: "Signed by all parties. You can now proceed to payment.", - stage: 2, - }, - PNR_GENERATED: { - title: "Payment reference generated", - description: - "A payment reference number has been generated for this booking.", - stage: 3, - }, - PAYMENT_VERIFICATION_IN_PROGRESS: { - title: "Verifying payment", - description: "Your payment is being verified.", - stage: 3, - }, - PAID: { - title: "Payment confirmed", - description: "Payment has been confirmed for this booking.", - stage: 3, - }, - IN_TRANSIT: { - title: "Cargo moving", - description: "Your shipment is currently moving through the rail network.", - stage: 3, - }, - PENDING_CONSOLIDATION: { - title: "Pending consolidation", - description: "Awaiting a consolidation partner shipment.", - stage: 3, - }, - CONSOLIDATED: { - title: "Consolidated", - description: "Cargo has been consolidated with a partner shipment.", - stage: 3, - }, - COMPLETED: { - title: "Service complete", - description: "Cargo delivered and service successfully terminated.", - stage: 4, - }, - DELIVERED: { - title: "Service complete", - description: "Cargo delivered and service successfully terminated.", - stage: 4, - }, - REJECTED: { - title: "Booking rejected", - description: "This booking request has been rejected.", - stage: -1, - }, - CANCELLED: { - title: "Booking cancelled", - description: "This booking process has been terminated.", - stage: -1, - }, -}; - -const REQUIRED_DOC_FIELDS = [ - { key: "commercial_invoice", label: "Commercial Invoice" }, - { key: "packing_list", label: "Packing List" }, - { key: "certificate_of_origin", label: "Certificate of Origin" }, - { key: "letter_of_credit", label: "Letter of Credit / LC" }, -]; - -const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; -const isDraftLike = (s: string) => s === "DRAFT" || s === "CHANGES_REQUESTED"; - -function fmtDate(value?: string | null) { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) ? "—" : format(d, "MMM d, yyyy"); -} - -function yardLabel(y?: Freight.IBooking["originYard"]) { - return y?.label ?? y?.code ?? "—"; -} - -function containerSummary(b: Freight.IBooking) { - if (b.containers?.length) { - return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", "); - } - return b.freightType === "BULK" ? "Bulk cargo" : "—"; -} - -function bookingSubtitle(b: Freight.IBooking) { - const cargo = - b.freightSubtype || - (b.freightType === "BULK" ? "Bulk freight" : "Container freight"); - const load = containerSummary(b); - const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`; - return [cargo, load, route].filter((p) => p && p !== "—").join(" · "); -} - -// ─── Page Entry ─────────────────────────────────────────────────────────────── - -export default function BookingDetailPage() { - const { id } = useParams<{ id: string }>(); - const queryClient = useQueryClient(); - - const { - data: booking, - isLoading, - isError, - error, - } = useQuery( - api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }), - ); - - const refetchBooking = () => { - queryClient.invalidateQueries({ - queryKey: api.bookings.get.queryKey({ id: id! }), - }); - }; - - if (isLoading) { - return ( -
- - - - Loading booking details… - - -
- ); - } - - if (isError || !booking) { - return ( - - - - - - - {isError ? "Failed to load booking" : "Booking not found"} - - {isError && ( - - {error instanceof Error - ? error.message - : "An unexpected error occurred."} - - )} - - - ); - } - - if (isDraftLike(booking.status)) { - return ( - - ); - } - return ; -} - -// ─── Draft View ─────────────────────────────────────────────────────────────── - -function DraftBookingView({ - booking, - onBookingUpdated, -}: { - booking: Freight.IBooking; - onBookingUpdated: () => void; -}) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const fileInputRefs = useRef>({}); - const documentsRef = useRef(null); - - const [selectedFiles, setSelectedFiles] = useState< - Record - >({}); - const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - const [cancelReason, setCancelReason] = useState(""); - const [docError, setDocError] = useState(""); - - const anyFileSelected = Object.values(selectedFiles).some(Boolean); - const uploadedCodes = useMemo( - () => new Set(booking.files?.map((f) => f.code) ?? []), - [booking.files], - ); - const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) => - uploadedCodes.has(d.key), - ).length; - const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; - - const { data: generatedPricing } = useQuery({ - ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), - enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, - }); - const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; - - const uploadMutation = useMutation({ - mutationFn: (files: Record) => - api.bookings.uploadDocuments.call({ id: booking.id, files }), - onSuccess: () => { - setSelectedFiles({}); - setDocError(""); - onBookingUpdated(); - }, - }); - - const submitMutation = useMutation({ - mutationFn: () => api.bookings.submit.call({ id: booking.id }), - onSuccess: () => { - onBookingUpdated(); - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - }, - }); - - const cancelMutation = useMutation({ - mutationFn: (reason: string) => - api.bookings.cancel.call({ id: booking.id, reason }), - onSuccess: () => { - setCancelDialogOpen(false); - onBookingUpdated(); - }, - }); - - function handleFileSelect(key: string, file: File | null) { - setSelectedFiles((prev) => ({ ...prev, [key]: file })); - } - - function handleUploadAll() { - const filesToUpload: Record = {}; - for (const doc of REQUIRED_DOC_FIELDS) { - if (selectedFiles[doc.key]) - filesToUpload[doc.key] = selectedFiles[doc.key]!; - } - if (Object.keys(filesToUpload).length === 0) return; - uploadMutation.mutate(filesToUpload); - } - - function handleSubmitRequest() { - const missing = REQUIRED_DOC_FIELDS.filter( - (doc) => !uploadedCodes.has(doc.key), - ); - if (missing.length > 0) { - setDocError("Please upload all required documents before submitting."); - documentsRef.current?.scrollIntoView({ behavior: "smooth" }); - return; - } - submitMutation.mutate(); - } - - const stepDone = allDocsUploaded; - const completeStep = stepDone ? 3 : uploadedCount > 0 ? 2 : 2; - - return ( - - } - label="Continue editing" - onClick={() => navigate(`/bookings/${booking.id}/edit`)} - /> - } - /> - - {booking.status === "CHANGES_REQUESTED" && - booking.latestChangeRequestNote && ( - } - title="Changes requested by staff" - > - {booking.latestChangeRequestNote} - - )} - - - - - - {/* Complete your booking */} - - - Complete your booking - - Step {completeStep} of 3 - - - - navigate(`/bookings/${booking.id}/edit`)} - > - Edit - - } - /> - - documentsRef.current?.scrollIntoView({ - behavior: "smooth", - }) - } - > - Upload - - ) - } - /> - - - - - - - - {/* Documents (uploadable) */} - - - - Documents - - - - - {docError && ( - } - className="mb-3" - > - {docError} - - )} - - - {REQUIRED_DOC_FIELDS.map((doc, i) => { - const isUploaded = uploadedCodes.has(doc.key); - const selected = selectedFiles[doc.key]; - const file = booking.files?.find((f) => f.code === doc.key); - return ( - } - /> - ) : ( - <> - { - fileInputRefs.current[doc.key] = el; - }} - type="file" - accept=".pdf,.jpg,.jpeg,.png" - className="hidden" - onChange={(e) => - handleFileSelect( - doc.key, - e.target.files?.[0] ?? null, - ) - } - /> - - - {selected && ( - - )} - - - ) - } - /> - ); - })} - - - {anyFileSelected && ( - - )} - - - } - right={ - <> - - - setCancelDialogOpen(true)} /> - - } - /> - - setCancelDialogOpen(false)} - title={Cancel booking} - radius="lg" - centered - > - - - Are you sure you want to cancel {booking.reference} - ? This action cannot be undone. - - setCancelReason(e.currentTarget.value)} - radius="md" - data-autofocus - /> - - - - - - - - ); -} - -// ─── Readonly View ──────────────────────────────────────────────────────────── - -function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { - const navigate = useNavigate(); - const status = booking.status as string; - - const payMutation = useMutation({ - mutationFn: () => api.bookings.pay.call({ id: booking.id }), - onSuccess: (data) => { - if (data.redirectUrl) window.location.href = data.redirectUrl; - }, - }); - - const pricing = booking.pricingBreakdown; - const canPay = - status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID"; - - return ( - - - {canPay && ( - } - label={payMutation.isPending ? "Processing…" : "Pay now"} - onClick={() => payMutation.mutate()} - disabled={payMutation.isPending} - /> - )} - - } - /> - - - - - - - - - {booking.files && booking.files.length > 0 && ( - - - Documents - - {booking.files.length} files - - - - {booking.files.map((file, i) => ( - } - /> - } - /> - ))} - - - )} - - - - } - right={ - <> - - - - - } - /> - - ); -} - -// ─── Layout primitives ──────────────────────────────────────────────────────── - -function PageShell({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ); -} - -function BodyGrid({ left, right }: { left: ReactNode; right: ReactNode }) { - return ( -
-
{left}
-
- {right} -
-
- ); -} - -const SectionCard = (() => { - type Props = { - children: ReactNode; - className?: string; - ref?: React.Ref; - }; - const Comp = ({ children, className, ref }: Props) => ( -
- {children} -
- ); - return Comp; -})(); - -function CardTitle({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -// ─── Page header ────────────────────────────────────────────────────────────── - -function PageHeader({ - booking, - actions, -}: { - booking: Freight.IBooking; - actions: ReactNode; -}) { - const status = booking.status as string; - const negative = isNegative(status); - const draft = isDraftLike(status); - - const dotColor = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371"; - const pillBg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; - const pillBorder = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; - const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; - const isExport = booking.tradeDirection === "EXPORT"; - - return ( - - - - - {booking.reference} - - - - {status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())} - - - {isExport ? ( - - ) : ( - - )} - {isExport ? "Export" : "Import"} - - - - {bookingSubtitle(booking)} - - - - {actions} - - - ); -} - -function HeaderButton({ - label, - icon, - onClick, - dark, - green, - disabled, -}: { - label: string; - icon: ReactNode; - onClick?: () => void; - dark?: boolean; - green?: boolean; - disabled?: boolean; -}) { - const base = - "inline-flex items-center gap-2 rounded-[10px] px-4 py-[11px] text-[13px] font-bold transition-colors disabled:opacity-60"; - const variant = green - ? "bg-[#0EA371] text-white hover:bg-[#0A8A5F]" - : dark - ? "bg-[#0C1A2B] text-white hover:bg-[#16273A]" - : "border border-[#E6ECF2] bg-white text-[#10202F] hover:border-[#CBD5E1]"; - return ( - - ); -} - -// ─── Status hero + tracker ──────────────────────────────────────────────────── - -function StatusHero({ booking }: { booking: Freight.IBooking }) { - const status = booking.status as string; - const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; - const negative = isNegative(status); - const draft = isDraftLike(status); - - const tone: "green" | "slate" | "red" = negative - ? "red" - : draft - ? "slate" - : "green"; - const tileBg = - tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1"; - const tileFg = - tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371"; - const HeroIcon = negative - ? AlertTriangle - : draft - ? FileText - : (PROGRESS_STAGES[cfg.stage]?.icon ?? History); - - const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled"; - const chipValue = fmtDate( - draft || negative ? booking.updatedAt : booking.scheduledDate, - ); - - return ( - - - -
- -
- - - {cfg.title} - - - {cfg.description} - - -
- - - - - {chipLabel} - - - {chipValue} - - - -
- - - - -
- ); -} - -function ProgressTracker({ - current, - tone = "green", - negative, -}: { - current: number; - tone?: "green" | "ink"; - negative?: boolean; -}) { - const last = PROGRESS_STAGES.length - 1; - const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; - const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; - const activeSub = tone === "ink" ? "#475569" : "#0A6F4D"; - - return ( -
- {PROGRESS_STAGES.map((stage, idx) => { - const state = - idx < current ? "done" : idx === current ? "active" : "idle"; - const Icon = stage.icon; - const reachedLeft = current >= idx && current >= 0; - const reachedRight = current > idx && current >= 0; - return ( -
-
- -
- {state === "done" ? ( - - ) : state === "active" ? ( - - ) : null} -
- -
- - {stage.label} - - - {state === "done" - ? "Completed" - : state === "active" - ? negative - ? "Stopped" - : "In progress" - : "Pending"} - -
- ); - })} -
- ); -} - -// ─── Shipment details ───────────────────────────────────────────────────────── - -function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) { - const rows: [string, string][][] = [ - [ - ["Origin yard", yardLabel(booking.originYard)], - ["Destination yard", yardLabel(booking.destinationYard)], - ], - [ - ["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"], - ["Commodity", booking.freightSubtype || "—"], - ], - [ - ["Containers / load", containerSummary(booking)], - [ - "Total weight (VGM)", - booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—", - ], - ], - [ - [ - "Service type", - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only", - ], - [ - "Equipment return", - booking.equipmentReturn === "WITH_RETURN" - ? "With return" - : "Without return", - ], - ], - [ - [ - "Trade direction", - booking.tradeDirection === "IMPORT" ? "Import" : "Export", - ], - ["Scheduled date", fmtDate(booking.scheduledDate)], - ], - [ - ["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"], - ["Assigned train", booking.trainId ?? "Not yet assigned"], - ], - ]; - - return ( - - - Shipment Details - - - {booking.contractType === "RENEWAL" - ? "Renewal contract" - : "New contract"} - - - - {rows.map((pair, i) => ( -
- {pair.map(([k, v]) => ( -
- - {k} - - - {v} - -
- ))} -
- ))} -
-
- ); -} - -// ─── Documents ──────────────────────────────────────────────────────────────── - -function DocRow({ - title, - meta, - status, - action, - last, -}: { - title: string; - meta: string; - status: "verified" | "ready" | "missing"; - action: ReactNode; - last?: boolean; -}) { - const tileBg = - status === "missing" - ? "#F1F4F7" - : status === "ready" - ? "#EAF1FB" - : "#F1F4F7"; - const tileFg = status === "ready" ? "#2E5B96" : "#475569"; - - return ( -
-
- -
-
- - {title} - - {meta} -
- {status === "verified" && ( - - - Verified - - )} - {status === "ready" && ( - - Ready - - )} - {action} -
- ); -} - -function IconSquare({ icon, href }: { icon: ReactNode; href?: string | null }) { - const cls = - "flex size-[34px] items-center justify-center rounded-[8px] border border-[#E6ECF2] text-[#6B7C8E] transition-colors hover:border-[#CBD5E1] hover:text-[#10202F]"; - if (href) { - return ( - - {icon} - - ); - } - return {icon}; -} - -function CountChip({ uploaded, total }: { uploaded: number; total: number }) { - const done = uploaded === total; - return ( - - {done && } - {uploaded}/{total} uploaded - - ); -} - -// ─── Steps (draft) ──────────────────────────────────────────────────────────── - -function StepLine({ - index, - title, - desc, - action, - done, - active, - highlight, -}: { - index: number; - title: string; - desc: string; - action?: ReactNode; - done?: boolean; - active?: boolean; - highlight?: boolean; -}) { - return ( -
-
- {done ? : index} -
-
- {title} - {desc} -
- {action} -
- ); -} - -function StepGhostButton({ - children, - onClick, -}: { - children: ReactNode; - onClick?: () => void; -}) { - return ( - - ); -} - -// ─── Pricing / estimate (right column) ──────────────────────────────────────── - -type Pricing = Freight.PricingBreakdown | null | undefined; - -function priceLineItems(pricing: Pricing) { - return (pricing?.lineItems ?? []).map((li) => ({ - label: li.description, - value: `${li.amount.toLocaleString()} ${li.currency}`, - })); -} - -function priceTotal(pricing: Pricing) { - if (!pricing) return "—"; - const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0); - return `${total.toLocaleString()} ${pricing.currency}`; -} - -function EstimateCard({ - pricing, - title, - chip, -}: { - pricing: Pricing; - title: string; - chip: string; -}) { - const items = priceLineItems(pricing); - return ( - - - {title} - - {chip} - - - - - {priceTotal(pricing)} - - - A firm price is confirmed after EDR reviews your booking. - - - {items.length > 0 && ( - <> - - - {items.map((it) => ( - - {it.label} - - {it.value} - - - ))} - - - - Estimated total - - - {priceTotal(pricing)} - - - - )} - - ); -} - -function PaymentCard({ - booking, - pricing, -}: { - booking: Freight.IBooking; - pricing: Pricing; -}) { - const items = priceLineItems(pricing); - const paid = booking.paymentStatus === "PAID"; - const total = priceTotal(pricing); - - return ( - - - Payment - - {paid - ? "Paid" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} - - - - - {total} - - {paid && ( - - Paid · {fmtDate(booking.updatedAt)} - - )} - - {items.length > 0 && ( - <> - - - {items.map((it) => ( - - {it.label} - - {it.value} - - - ))} - - - - Total - - - {total} - - - - )} - - - ); -} - -// ─── Schedule / consignment (right column) ──────────────────────────────────── - -function ScheduleCard({ - booking, - title, - consignment, -}: { - booking: Freight.IBooking; - title: string; - consignment?: boolean; -}) { - const rows: { label: string; value: ReactNode; muted?: boolean }[] = - consignment - ? [ - { label: "Consignment ID", value: booking.reference }, - { - label: "Service", - value: - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only", - }, - { - label: "Equipment return", - value: - booking.equipmentReturn === "WITH_RETURN" - ? "With return" - : "Without return", - }, - { - label: "Assigned train", - value: booking.trainId ?? "Not yet assigned", - muted: !booking.trainId, - }, - { label: "Scheduled", value: fmtDate(booking.scheduledDate) }, - { - label: "Consolidation", - value: booking.allowConsolidation ? "Allowed" : "Not allowed", - }, - ] - : [ - { - label: "Service", - value: - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only", - }, - { - label: "Equipment return", - value: - booking.equipmentReturn === "WITH_RETURN" - ? "With return" - : "Without return", - }, - { label: "Proposed date", value: fmtDate(booking.scheduledDate) }, - { - label: "Assigned train", - value: booking.trainId ?? "Not yet assigned", - muted: !booking.trainId, - }, - { - label: "Consolidation", - value: booking.allowConsolidation ? "Allowed" : "Not allowed", - }, - ]; - - return ( - - - {title} - - - {rows.map((r, i) => ( -
- {r.label} - - {r.value} - -
- ))} -
-
- ); -} - -// ─── Activity timeline (readonly) ───────────────────────────────────────────── - -function ActivityCard({ booking }: { booking: Freight.IBooking }) { - const events = [ - booking.signedByCeoAt && { - at: booking.signedByCeoAt, - title: "Contract fully executed", - note: "Signed by all parties", - }, - booking.signedByDirectorAt && { - at: booking.signedByDirectorAt, - title: "Director signed contract", - note: "Awaiting final signature", - }, - booking.approvedByStaffAt && { - at: booking.approvedByStaffAt, - title: "Booking approved", - note: "Cleared by EDR staff", - }, - { - at: booking.createdAt, - title: "Booking created", - note: "Request drafted by customer", - }, - ].filter(Boolean) as { at: string; title: string; note: string }[]; - - if (events.length === 0) return null; - - return ( - - - Activity - - Full history - - - - {events.map((e, i) => { - const first = i === 0; - const lastItem = i === events.length - 1; - return ( -
-
-
- {first && } -
- {!lastItem && ( -
- )} -
-
- - - {e.title} - - {first && ( - - Latest - - )} - - - {fmtDate(e.at)} · {e.note} - -
-
- ); - })} - - - ); -} - -// ─── Support card (dark) ────────────────────────────────────────────────────── - -function SupportCard({ onCancel }: { onCancel?: () => void }) { - return ( -
- -
- -
- - - Need help? - - - EDR operations team - - -
- - Questions about this shipment, documents, or delivery? Our operations - team can help. - - - - - -
- ); -} - -// ─── Contract card (readonly, signature flow) ───────────────────────────────── - -function ContractCard({ - booking, - navigate, -}: { - booking: Freight.IBooking; - navigate: ReturnType; -}) { - const s = booking.status as string; - const config: Record< - string, - { - title: string; - description: string; - buttonLabel?: string; - urgent?: boolean; - } - > = { - APPROVED_PENDING_SIGNATURE: { - title: "Contract being prepared", - description: - "Your booking has been approved. The contract will be available shortly.", - }, - CONTRACT_READY: { - title: "Action required — sign your contract", - description: - "Your contract is ready. Review the agreement and apply your digital signature to proceed.", - buttonLabel: "View & sign contract", - urgent: true, - }, - SIGNED_CUSTOMER: { - title: "You have signed the contract", - description: - "Your signature has been submitted. Awaiting the final staff signature.", - buttonLabel: "View contract", - }, - FULLY_EXECUTED: { - title: "Contract fully executed", - description: - "The contract has been signed by all parties. You can now proceed to payment.", - buttonLabel: "View contract", - }, - }; - const c = config[s]; - if (!c) return null; - const urgent = c.urgent; - - return ( -
- -
- -
- - - {c.title} - - - {c.description} - - -
- {c.buttonLabel && ( - - )} -
- ); -} - -// ─── Notices ────────────────────────────────────────────────────────────────── - -function NoticeBanner({ - tone, - icon, - title, - children, - className, -}: { - tone: "amber" | "red"; - icon: ReactNode; - title?: string; - children: ReactNode; - className?: string; -}) { - const styles = - tone === "amber" - ? "border-[#F6E2BC] bg-[#FDF3E0] text-[#9A5B00]" - : "border-[#F3C8C1] bg-[#FBEAE7] text-[#A93226]"; - return ( -
- {icon} -
- {title && {title}} - {children} -
-
- ); -} - -function MutationErrors({ - mutations, -}: { - mutations: { isError: boolean; error: unknown }[]; -}) { - const errored = mutations.filter((m) => m.isError); - if (errored.length === 0) return null; - return ( - <> - {errored.map((m, i) => ( - } - title="Something went wrong" - > - {m.error instanceof Error - ? m.error.message - : "An unexpected error occurred."} - - ))} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx new file mode 100644 index 000000000..03919c4b9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -0,0 +1,421 @@ +import { + ActionIcon, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + Download, + Pencil, + Send, + Upload, + X, + XCircle, +} from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { REQUIRED_DOC_FIELDS } from "./constants"; +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 { MutationErrors, NoticeBanner } from "./components/Notices"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { StepGhostButton, StepLine } from "./components/Steps"; +import { SupportCard } from "./components/SupportCard"; +import { BodyGrid } from "./components/layout"; + +export function DraftBookingView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const fileInputRefs = useRef>({}); + const documentsRef = useRef(null); + + const [selectedFiles, setSelectedFiles] = useState< + Record + >({}); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + const [docError, setDocError] = useState(""); + + const anyFileSelected = Object.values(selectedFiles).some(Boolean); + const uploadedCodes = useMemo( + () => new Set(booking.files?.map((f) => f.code) ?? []), + [booking.files], + ); + const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) => + uploadedCodes.has(d.key), + ).length; + const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; + + const { data: generatedPricing } = useQuery({ + ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + }); + const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; + + const uploadMutation = useMutation({ + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), + onSuccess: () => { + setSelectedFiles({}); + setDocError(""); + onBookingUpdated(); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: () => { + onBookingUpdated(); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + setCancelDialogOpen(false); + onBookingUpdated(); + }, + }); + + function handleFileSelect(key: string, file: File | null) { + setSelectedFiles((prev) => ({ ...prev, [key]: file })); + } + + function handleUploadAll() { + const filesToUpload: Record = {}; + for (const doc of REQUIRED_DOC_FIELDS) { + if (selectedFiles[doc.key]) + filesToUpload[doc.key] = selectedFiles[doc.key]!; + } + if (Object.keys(filesToUpload).length === 0) return; + uploadMutation.mutate(filesToUpload); + } + + function handleSubmitRequest() { + const missing = REQUIRED_DOC_FIELDS.filter( + (doc) => !uploadedCodes.has(doc.key), + ); + if (missing.length > 0) { + setDocError("Please upload all required documents before submitting."); + documentsRef.current?.scrollIntoView({ behavior: "smooth" }); + return; + } + submitMutation.mutate(); + } + + const completeStep = allDocsUploaded ? 3 : 2; + + return ( + + } + label="Continue editing" + onClick={() => navigate(`/bookings/${booking.id}/edit`)} + /> + } + /> + + {booking.status === "CHANGES_REQUESTED" && + booking.latestChangeRequestNote && ( + } + title="Changes requested by staff" + > + {booking.latestChangeRequestNote} + + )} + + + + + + {/* Complete your booking */} + + + Complete your booking + + Step {completeStep} of 3 + + + + navigate(`/bookings/${booking.id}/edit`)} + > + Edit + + } + /> + + documentsRef.current?.scrollIntoView({ + behavior: "smooth", + }) + } + > + Upload + + ) + } + /> + + + + + + + + {/* Documents (uploadable) */} + + + + Documents + + + + + {docError && ( + } + style={{ marginBottom: 12 }} + > + {docError} + + )} + + + {REQUIRED_DOC_FIELDS.map((doc, i) => { + const isUploaded = uploadedCodes.has(doc.key); + const selected = selectedFiles[doc.key]; + const file = booking.files?.find((f) => f.code === doc.key); + return ( + } + /> + ) : ( + <> + { + fileInputRefs.current[doc.key] = el; + }} + type="file" + accept=".pdf,.jpg,.jpeg,.png" + style={{ display: "none" }} + onChange={(e) => + handleFileSelect( + doc.key, + e.target.files?.[0] ?? null, + ) + } + /> + + + {selected && ( + handleFileSelect(doc.key, null)} + style={{ color: "#C0392B" }} + > + + + )} + + + ) + } + /> + ); + })} + + + {anyFileSelected && ( + + )} + + + } + right={ + <> + + + setCancelDialogOpen(true)} /> + + } + /> + + setCancelDialogOpen(false)} + title={Cancel booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference} + ? This action cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx new file mode 100644 index 000000000..917894626 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -0,0 +1,106 @@ +import { Box, Group, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { CreditCard, Download } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { ActivityCard } from "./components/ActivityCard"; +import { ContractCard } from "./components/ContractCard"; +import { DocRow, IconSquare } from "./components/Documents"; +import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; +import { HeaderButton, PageHeader } from "./components/PageHeader"; +import { PaymentCard } from "./components/pricing"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { SupportCard } from "./components/SupportCard"; + +export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { + const navigate = useNavigate(); + const status = booking.status as string; + + const payMutation = useMutation({ + mutationFn: () => api.bookings.pay.call({ id: booking.id }), + onSuccess: (data) => { + if (data.redirectUrl) window.location.href = data.redirectUrl; + }, + }); + + const pricing = booking.pricingBreakdown; + const canPay = + status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID"; + + return ( + + } + label={payMutation.isPending ? "Processing…" : "Pay now"} + onClick={() => payMutation.mutate()} + disabled={payMutation.isPending} + /> + ) + } + /> + + + + + + + + + {booking.files && booking.files.length > 0 && ( + + + Documents + + {booking.files.length} files + + + + {booking.files.map((file, i) => ( + } + /> + } + /> + ))} + + + )} + + + + } + right={ + <> + + + + + } + /> + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx new file mode 100644 index 000000000..851f5f92c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx @@ -0,0 +1,114 @@ +import { Box, Group, Text } from "@mantine/core"; +import { Train } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { fmtDate } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +export function ActivityCard({ booking }: { booking: Freight.IBooking }) { + const events = [ + booking.signedByCeoAt && { + at: booking.signedByCeoAt, + title: "Contract fully executed", + note: "Signed by all parties", + }, + booking.signedByDirectorAt && { + at: booking.signedByDirectorAt, + title: "Director signed contract", + note: "Awaiting final signature", + }, + booking.approvedByStaffAt && { + at: booking.approvedByStaffAt, + title: "Booking approved", + note: "Cleared by EDR staff", + }, + { + at: booking.createdAt, + title: "Booking created", + note: "Request drafted by customer", + }, + ].filter(Boolean) as { at: string; title: string; note: string }[]; + + if (events.length === 0) return null; + + return ( + + + Activity + + Full history + + + + {events.map((e, i) => { + const first = i === 0; + const lastItem = i === events.length - 1; + return ( + + + + {first && } + + {!lastItem && ( + + )} + + + + + {e.title} + + {first && ( + + Latest + + )} + + + {fmtDate(e.at)} · {e.note} + + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx new file mode 100644 index 000000000..e9836703c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx @@ -0,0 +1,103 @@ +import { Box, Button, Group, Paper, Text } from "@mantine/core"; +import { FileSignature } from "lucide-react"; +import type { useNavigate } from "react-router-dom"; + +import type { Freight } from "@edr/types"; + +const CONTRACT_CONFIG: Record< + string, + { + title: string; + description: string; + buttonLabel?: string; + urgent?: boolean; + } +> = { + APPROVED_PENDING_SIGNATURE: { + title: "Contract being prepared", + description: + "Your booking has been approved. The contract will be available shortly.", + }, + CONTRACT_READY: { + title: "Action required — sign your contract", + description: + "Your contract is ready. Review the agreement and apply your digital signature to proceed.", + buttonLabel: "View & sign contract", + urgent: true, + }, + SIGNED_CUSTOMER: { + title: "You have signed the contract", + description: + "Your signature has been submitted. Awaiting the final staff signature.", + buttonLabel: "View contract", + }, + FULLY_EXECUTED: { + title: "Contract fully executed", + description: + "The contract has been signed by all parties. You can now proceed to payment.", + buttonLabel: "View contract", + }, +}; + +export function ContractCard({ + booking, + navigate, +}: { + booking: Freight.IBooking; + navigate: ReturnType; +}) { + const c = CONTRACT_CONFIG[booking.status as string]; + if (!c) return null; + const urgent = c.urgent; + + return ( + + + + + + + + + {c.title} + + + {c.description} + + + + {c.buttonLabel && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx new file mode 100644 index 000000000..616c868ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx @@ -0,0 +1,161 @@ +import { Box, Group, Text } from "@mantine/core"; +import { CheckCircle2, FileText } from "lucide-react"; +import type { ReactNode } from "react"; + +export function DocRow({ + title, + meta, + status, + action, + last, +}: { + title: string; + meta: string; + status: "verified" | "ready" | "missing"; + action: ReactNode; + last?: boolean; +}) { + const tileBg = status === "ready" ? "#EAF1FB" : "#F1F4F7"; + const tileFg = status === "ready" ? "#2E5B96" : "#475569"; + + return ( + + + + + + + {title} + + + {meta} + + + {status === "verified" && ( + + + Verified + + )} + {status === "ready" && ( + + Ready + + )} + {action} + + ); +} + +export function IconSquare({ + icon, + href, +}: { + icon: ReactNode; + href?: string | null; +}) { + const style: React.CSSProperties = { + flexShrink: 0, + width: 34, + height: 34, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + border: "1px solid #E6ECF2", + color: "#6B7C8E", + }; + if (href) { + return ( + + {icon} + + ); + } + return ( + + {icon} + + ); +} + +export function CountChip({ + uploaded, + total, +}: { + uploaded: number; + total: number; +}) { + const done = uploaded === total; + return ( + + {done && } + {uploaded}/{total} uploaded + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx new file mode 100644 index 000000000..77565aed8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx @@ -0,0 +1,77 @@ +import { Box, Group, Text } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import type { ReactNode } from "react"; + +export function NoticeBanner({ + tone, + icon, + title, + children, + style, +}: { + tone: "amber" | "red"; + icon: ReactNode; + title?: string; + children: ReactNode; + style?: React.CSSProperties; +}) { + const palette = + tone === "amber" + ? { border: "#F6E2BC", bg: "#FDF3E0", color: "#9A5B00" } + : { border: "#F3C8C1", bg: "#FBEAE7", color: "#A93226" }; + + return ( + + + {icon} + + + {title && ( + + {title} + + )} + + {children} + + + + ); +} + +export function MutationErrors({ + mutations, +}: { + mutations: { isError: boolean; error: unknown }[]; +}) { + const errored = mutations.filter((m) => m.isError); + if (errored.length === 0) return null; + return ( + <> + {errored.map((m, i) => ( + } + title="Something went wrong" + > + {m.error instanceof Error + ? m.error.message + : "An unexpected error occurred."} + + ))} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx new file mode 100644 index 000000000..f326f86f0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -0,0 +1,125 @@ +import { Box, Button, Group, Stack, Text } from "@mantine/core"; +import { ArrowDownLeft, ArrowUpRight } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { Freight } from "@edr/types"; + +import { bookingSubtitle, isDraftLike, isNegative } from "../utils"; + +export function PageHeader({ + booking, + actions, +}: { + booking: Freight.IBooking; + actions: ReactNode; +}) { + const status = booking.status as string; + const negative = isNegative(status); + const draft = isDraftLike(status); + + const dotColor = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371"; + const pillBg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; + const pillBorder = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; + const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; + const isExport = booking.tradeDirection === "EXPORT"; + + return ( + + + + + {booking.reference} + + + + {status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())} + + + {isExport ? : } + {isExport ? "Export" : "Import"} + + + + {bookingSubtitle(booking)} + + + + {actions} + + + ); +} + +export function HeaderButton({ + label, + icon, + onClick, + dark, + green, + disabled, +}: { + label: string; + icon: ReactNode; + onClick?: () => void; + dark?: boolean; + green?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx new file mode 100644 index 000000000..18eae1bf5 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -0,0 +1,84 @@ +import { Box, Group, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +import type { Freight } from "@edr/types"; + +import { fmtDate } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +type Row = { label: string; value: ReactNode; muted?: boolean }; + +export function ScheduleCard({ + booking, + title, + consignment, +}: { + booking: Freight.IBooking; + title: string; + consignment?: boolean; +}) { + const service = + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail + Forwarding" + : "Rail only"; + const equipmentReturn = + booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; + const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed"; + const assignedTrain: Row = { + label: "Assigned train", + value: booking.trainId ?? "Not yet assigned", + muted: !booking.trainId, + }; + + const rows: Row[] = consignment + ? [ + { label: "Consignment ID", value: booking.reference }, + { label: "Service", value: service }, + { label: "Equipment return", value: equipmentReturn }, + assignedTrain, + { label: "Scheduled", value: fmtDate(booking.scheduledDate) }, + { label: "Consolidation", value: consolidation }, + ] + : [ + { label: "Service", value: service }, + { label: "Equipment return", value: equipmentReturn }, + { label: "Proposed date", value: fmtDate(booking.scheduledDate) }, + assignedTrain, + { label: "Consolidation", value: consolidation }, + ]; + + return ( + + + {title} + + + {rows.map((r, i) => ( + + + {r.label} + + + {r.value} + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx new file mode 100644 index 000000000..259a5c113 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -0,0 +1,106 @@ +import { Box, Group, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { containerSummary, fmtDate, yardLabel } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) { + const rows: [string, string][][] = [ + [ + ["Origin yard", yardLabel(booking.originYard)], + ["Destination yard", yardLabel(booking.destinationYard)], + ], + [ + ["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"], + ["Commodity", booking.freightSubtype || "—"], + ], + [ + ["Containers / load", containerSummary(booking)], + [ + "Total weight (VGM)", + booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—", + ], + ], + [ + [ + "Service type", + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail + Forwarding" + : "Rail only", + ], + [ + "Equipment return", + booking.equipmentReturn === "WITH_RETURN" + ? "With return" + : "Without return", + ], + ], + [ + [ + "Trade direction", + booking.tradeDirection === "IMPORT" ? "Import" : "Export", + ], + ["Scheduled date", fmtDate(booking.scheduledDate)], + ], + [ + ["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"], + ["Assigned train", booking.trainId ?? "Not yet assigned"], + ], + ]; + + return ( + + + Shipment Details + + + {booking.contractType === "RENEWAL" + ? "Renewal contract" + : "New contract"} + + + + {rows.map((pair, i) => ( + + {pair.map(([k, v]) => ( + + + {k} + + + {v} + + + ))} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx new file mode 100644 index 000000000..b85bd81b1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -0,0 +1,213 @@ +import { Box, Group, Text } from "@mantine/core"; +import { AlertTriangle, Check, Clock, FileText, History } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { PROGRESS_STAGES, STATUS_MAP } from "../constants"; +import { fmtDate, isDraftLike, isNegative } from "../utils"; +import { SectionCard } from "./layout"; + +export function StatusHero({ booking }: { booking: Freight.IBooking }) { + const status = booking.status as string; + const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; + const negative = isNegative(status); + const draft = isDraftLike(status); + + const tone: "green" | "slate" | "red" = negative + ? "red" + : draft + ? "slate" + : "green"; + const tileBg = + tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1"; + const tileFg = + tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371"; + const HeroIcon = negative + ? AlertTriangle + : draft + ? FileText + : (PROGRESS_STAGES[cfg.stage]?.icon ?? History); + + const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled"; + const chipValue = fmtDate( + draft || negative ? booking.updatedAt : booking.scheduledDate, + ); + + return ( + + + + + + + + + {cfg.title} + + + {cfg.description} + + + + + + + + {chipLabel} + + + {chipValue} + + + + + + + + + + ); +} + +function ProgressTracker({ + current, + tone = "green", + negative, +}: { + current: number; + tone?: "green" | "ink"; + negative?: boolean; +}) { + const last = PROGRESS_STAGES.length - 1; + const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; + const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; + const activeSub = tone === "ink" ? "#475569" : "#0A6F4D"; + + return ( + + {PROGRESS_STAGES.map((stage, idx) => { + const state = + idx < current ? "done" : idx === current ? "active" : "idle"; + const Icon = stage.icon; + const reachedLeft = current >= idx && current >= 0; + const reachedRight = current > idx && current >= 0; + + const circleStyle: React.CSSProperties = + state === "idle" + ? { backgroundColor: "#EEF2F6", border: "1px solid #E1E7EE" } + : { + backgroundColor: state === "active" ? activeFill : "#0EA371", + boxShadow: + state === "active" ? `0 0 0 4px ${activeRing}` : undefined, + }; + + return ( + + + + + {state === "done" ? ( + + ) : state === "active" ? ( + + ) : null} + + + + + {stage.label} + + + {state === "done" + ? "Completed" + : state === "active" + ? negative + ? "Stopped" + : "In progress" + : "Pending"} + + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx new file mode 100644 index 000000000..cecc9639e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx @@ -0,0 +1,93 @@ +import { Box, Button, Group, Text } from "@mantine/core"; +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; + +export function StepLine({ + index, + title, + desc, + action, + done, + active, + highlight, +}: { + index: number; + title: string; + desc: string; + action?: ReactNode; + done?: boolean; + active?: boolean; + highlight?: boolean; +}) { + const badgeStyle: React.CSSProperties = done + ? { backgroundColor: "#0EA371", color: "#fff" } + : active + ? { backgroundColor: "#0C1A2B", color: "#fff" } + : { + backgroundColor: "#EEF2F6", + color: "#9AA8B5", + border: "1px solid #E1E7EE", + }; + + return ( + + + {done ? : index} + + + + {title} + + + {desc} + + + {action} + + ); +} + +export function StepGhostButton({ + children, + onClick, +}: { + children: ReactNode; + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx new file mode 100644 index 000000000..02fafab17 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx @@ -0,0 +1,61 @@ +import { Box, Button, Group, Paper, Text } from "@mantine/core"; +import { FileText, MessageSquare, XCircle } from "lucide-react"; + +export function SupportCard({ onCancel }: { onCancel?: () => void }) { + return ( + + + + + + + + Need help? + + + EDR operations team + + + + + Questions about this shipment, documents, or delivery? Our operations + team can help. + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx new file mode 100644 index 000000000..1e45e2d45 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx @@ -0,0 +1,55 @@ +import { Box, Flex, Paper, Stack, Text, type PaperProps } from "@mantine/core"; +import type { ReactNode, Ref } from "react"; + +export function PageShell({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +export function BodyGrid({ left, right }: { left: ReactNode; right: ReactNode }) { + return ( + + + {left} + + + {right} + + + ); +} + +interface SectionCardProps extends PaperProps { + children: ReactNode; + ref?: Ref; +} + +export function SectionCard({ children, ref, ...props }: SectionCardProps) { + return ( + + {children} + + ); +} + +export function CardTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx new file mode 100644 index 000000000..4017e5b54 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -0,0 +1,162 @@ +import { Box, Button, Group, Stack, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +function LineItems({ pricing }: { pricing: Pricing }) { + const items = priceLineItems(pricing); + if (items.length === 0) return null; + return ( + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + ); +} + +const Divider = () => ; + +export function EstimateCard({ + pricing, + title, + chip, +}: { + pricing: Pricing; + title: string; + chip: string; +}) { + const hasItems = priceLineItems(pricing).length > 0; + return ( + + + {title} + + {chip} + + + + + {priceTotal(pricing)} + + + A firm price is confirmed after EDR reviews your booking. + + + {hasItems && ( + <> + + + + + Estimated total + + + {priceTotal(pricing)} + + + + )} + + ); +} + +export function PaymentCard({ + booking, + pricing, +}: { + booking: Freight.IBooking; + pricing: Pricing; +}) { + const hasItems = priceLineItems(pricing).length > 0; + const paid = booking.paymentStatus === "PAID"; + const total = priceTotal(pricing); + + return ( + + + Payment + + {paid + ? "Paid" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + + {total} + + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + {hasItems && ( + <> + + + + + Total + + + {total} + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts new file mode 100644 index 000000000..80f0b7ab2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -0,0 +1,160 @@ +import { + ClipboardCheck, + FileText, + PackageCheck, + ShieldCheck, + Train, +} from "lucide-react"; + +export const PROGRESS_STAGES = [ + { + label: "Request", + icon: FileText, + statuses: ["DRAFT", "CHANGES_REQUESTED"], + }, + { + label: "Submitted", + icon: ClipboardCheck, + statuses: ["SUBMITTED", "PENDING_APPROVAL"], + }, + { + label: "Approved", + icon: ShieldCheck, + statuses: [ + "APPROVED_PENDING_SIGNATURE", + "APPROVED", + "CONTRACT_READY", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + ], + }, + { + label: "In Transit", + icon: Train, + statuses: [ + "PNR_GENERATED", + "PAYMENT_VERIFICATION_IN_PROGRESS", + "PAID", + "IN_TRANSIT", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", + ], + }, + { + label: "Complete", + icon: PackageCheck, + statuses: ["COMPLETED", "DELIVERED"], + }, +]; + +export const STATUS_MAP: Record< + string, + { title: string; description: string; stage: number } +> = { + DRAFT: { + title: "Draft — not submitted", + description: + "This booking is being prepared and hasn’t been submitted for review yet.", + stage: 0, + }, + CHANGES_REQUESTED: { + title: "Changes requested", + description: "Staff has requested changes. Please review and resubmit.", + stage: 0, + }, + SUBMITTED: { + title: "Submitted for review", + description: "Your booking has been submitted and is awaiting review.", + stage: 1, + }, + PENDING_APPROVAL: { + title: "Pending approval", + description: "Your booking is moving through the approval process.", + stage: 1, + }, + APPROVED_PENDING_SIGNATURE: { + title: "Approved — awaiting signature", + description: "Approved. Your contract will be ready to sign shortly.", + stage: 2, + }, + APPROVED: { + title: "Approved", + description: "Your booking has been fully approved.", + stage: 2, + }, + CONTRACT_READY: { + title: "Contract ready to sign", + description: + "Your contract is ready. Review and apply your signature to proceed.", + stage: 2, + }, + SIGNED_CUSTOMER: { + title: "Signed — awaiting staff", + description: + "Your signature has been submitted. Awaiting the final staff signature.", + stage: 2, + }, + FULLY_EXECUTED: { + title: "Contract fully executed", + description: "Signed by all parties. You can now proceed to payment.", + stage: 2, + }, + PNR_GENERATED: { + title: "Payment reference generated", + description: + "A payment reference number has been generated for this booking.", + stage: 3, + }, + PAYMENT_VERIFICATION_IN_PROGRESS: { + title: "Verifying payment", + description: "Your payment is being verified.", + stage: 3, + }, + PAID: { + title: "Payment confirmed", + description: "Payment has been confirmed for this booking.", + stage: 3, + }, + IN_TRANSIT: { + title: "Cargo moving", + description: "Your shipment is currently moving through the rail network.", + stage: 3, + }, + PENDING_CONSOLIDATION: { + title: "Pending consolidation", + description: "Awaiting a consolidation partner shipment.", + stage: 3, + }, + CONSOLIDATED: { + title: "Consolidated", + description: "Cargo has been consolidated with a partner shipment.", + stage: 3, + }, + COMPLETED: { + title: "Service complete", + description: "Cargo delivered and service successfully terminated.", + stage: 4, + }, + DELIVERED: { + title: "Service complete", + description: "Cargo delivered and service successfully terminated.", + stage: 4, + }, + REJECTED: { + title: "Booking rejected", + description: "This booking request has been rejected.", + stage: -1, + }, + CANCELLED: { + title: "Booking cancelled", + description: "This booking process has been terminated.", + stage: -1, + }, +}; + +export const REQUIRED_DOC_FIELDS = [ + { key: "commercial_invoice", label: "Commercial Invoice" }, + { key: "packing_list", label: "Packing List" }, + { key: "certificate_of_origin", label: "Certificate of Origin" }, + { key: "letter_of_credit", label: "Letter of Credit / LC" }, +]; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx new file mode 100644 index 000000000..02e0318fd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -0,0 +1,86 @@ +import { Box, Center, Loader, Stack, Text } from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle } from "lucide-react"; +import { useParams } from "react-router-dom"; + +import { api } from "@/services/api"; + +import { DraftBookingView } from "./DraftBookingView"; +import { PageShell, SectionCard } from "./components/layout"; +import { ReadonlyBookingView } from "./ReadonlyBookingView"; +import { isDraftLike } from "./utils"; + +export default function BookingDetailPage() { + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + + const { + data: booking, + isLoading, + isError, + error, + } = useQuery( + api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }), + ); + + const refetchBooking = () => { + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); + }; + + if (isLoading) { + return ( +
+ + + + Loading booking details… + + +
+ ); + } + + if (isError || !booking) { + return ( + + + + + + + + {isError ? "Failed to load booking" : "Booking not found"} + + {isError && ( + + {error instanceof Error + ? error.message + : "An unexpected error occurred."} + + )} + + + + ); + } + + if (isDraftLike(booking.status)) { + return ( + + ); + } + return ; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts new file mode 100644 index 000000000..031a2d38c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -0,0 +1,50 @@ +import { format } from "date-fns"; + +import type { Freight } from "@edr/types"; + +export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; +export const isDraftLike = (s: string) => + s === "DRAFT" || s === "CHANGES_REQUESTED"; + +export function fmtDate(value?: string | null) { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? "—" : format(d, "MMM d, yyyy"); +} + +export function yardLabel(y?: Freight.IBooking["originYard"]) { + return y?.label ?? y?.code ?? "—"; +} + +export function containerSummary(b: Freight.IBooking) { + if (b.containers?.length) { + return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", "); + } + return b.freightType === "BULK" ? "Bulk cargo" : "—"; +} + +export function bookingSubtitle(b: Freight.IBooking) { + const cargo = + b.freightSubtype || + (b.freightType === "BULK" ? "Bulk freight" : "Container freight"); + const load = containerSummary(b); + const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`; + return [cargo, load, route].filter((p) => p && p !== "—").join(" · "); +} + +// ─── Pricing helpers ────────────────────────────────────────────────────────── + +export type Pricing = Freight.PricingBreakdown | null | undefined; + +export function priceLineItems(pricing: Pricing) { + return (pricing?.lineItems ?? []).map((li) => ({ + label: li.description, + value: `${li.amount.toLocaleString()} ${li.currency}`, + })); +} + +export function priceTotal(pricing: Pricing) { + if (!pricing) return "—"; + const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0); + return `${total.toLocaleString()} ${pricing.currency}`; +}