From 3dff7ec189c03704e9300dc919572b89c0dbc103 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 27 Jun 2026 17:09:01 +0000 Subject: [PATCH] Refactor contract form steps and improve UI components - Updated Step 3: Cargo Scope to use Select and MultiSelect components for better user experience. - Simplified loading states and improved layout in Step 4: Route. - Enhanced Step 8: Review with a new SummaryItem component for better readability and organization. - Adjusted navigation labels in payment pages to reflect contracts instead of bookings. - Introduced RecentContractsSection component to display recent contracts with improved UI and functionality. - Added clamping logic for step navigation in useContractDraft to handle older drafts. --- .../modules/contracts/contracts.repository.ts | 2 + apps/edr-freight-web/portal/src/App.tsx | 15 +- .../portal/src/components/AppLayout.tsx | 43 +- .../portal/src/constants/apiConfig.ts | 4 +- .../src/pages/MyPortalPage/MyPortalPage.tsx | 42 +- .../MyPortalPage/components/HelloSection.tsx | 8 +- .../components/RecentActivitySection.tsx | 2 +- .../components/RecentContractsSection.tsx | 140 +++ .../MyPortalPage/components/StatsSection.tsx | 17 +- .../pages/MyPortalPage/components/index.ts | 1 + .../portal/src/pages/MyPortalPage/hooks.ts | 27 + .../components/ContainersCard.tsx | 6 +- .../components/ContractCard.tsx | 64 +- .../components/KeyFactsStrip.tsx | 74 +- .../components/StatusHero.tsx | 138 +-- .../components/SupportCard.tsx | 53 +- .../BookingDetailPage/components/layout.tsx | 23 +- .../portal/src/pages/bookings/MyBookings.tsx | 884 ------------------ .../new-booking-form/LocationPicker.tsx | 239 ++++- .../bookings/new-booking-form/shared.tsx | 41 +- .../pages/contracts/ContractDetailPage.tsx | 618 +++++++----- .../src/pages/contracts/ContractsList.tsx | 117 ++- .../src/pages/contracts/NewContractPage.tsx | 73 +- .../src/pages/contracts/contract-ui.tsx | 112 ++- .../new-contract-form/StepIndicator.tsx | 9 +- .../payment-currency-field.tsx | 130 +-- .../contracts/new-contract-form/schema.ts | 95 +- .../step0-operation-type.tsx | 143 +-- .../new-contract-form/step1-contract-type.tsx | 171 ++-- .../new-contract-form/step2-service-type.tsx | 228 ++--- .../new-contract-form/step3-cargo-scope.tsx | 188 ++-- .../new-contract-form/step4-route.tsx | 20 +- .../new-contract-form/step8-review.tsx | 446 ++++----- .../new-contract-form/useContractDraft.ts | 6 +- .../src/pages/payments/CheckPaymentPage.tsx | 16 +- .../src/pages/payments/PaymentFailurePage.tsx | 4 +- .../src/pages/payments/PaymentSuccessPage.tsx | 4 +- 37 files changed, 1824 insertions(+), 2379 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentContractsSection.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 6cbb14000..79573fcf4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -113,6 +113,8 @@ export class ContractsRepository extends BaseRepository { .leftJoinAndSelect('contract.company', 'company') .leftJoinAndSelect('contract.serviceType', 'serviceType') .leftJoinAndSelect('contract.routes', 'routes') + .leftJoinAndSelect('routes.originYard', 'routeOrigin') + .leftJoinAndSelect('routes.destinationYard', 'routeDestination') .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .where('contract.deleted_at IS NULL'); diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 966e6ecfd..9aff2fe1e 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,7 +1,6 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { useDisclosure } from "@mantine/hooks"; import { - CalendarCheck, Home, Layers, Loader2, @@ -36,7 +35,6 @@ import BillingPage from "./pages/billing/BillingPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; -import MyBookings from "./pages/bookings/MyBookings"; import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import ContractsList from "./pages/contracts/ContractsList"; @@ -178,11 +176,6 @@ function LandingRoute() { const sidebarItems: SidebarItem[] = [ { label: "Home", href: "/portal", icon: }, - { - label: "My Bookings", - href: "/bookings", - icon: , - }, { label: "Contracts", href: "/contracts", @@ -259,8 +252,12 @@ const App = () => { } > } /> - } /> - {/* Contract creation replaces the legacy booking wizard. */} + {/* Bookings live under contracts now — the standalone list is gone. + Legacy /bookings* entry points redirect into the contract flow. */} + } + /> } diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index ac7c8e092..0718cc657 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -122,16 +122,19 @@ function getActivePage( const navClassNames = (active: boolean) => { if (active) { + // Active tab gets the strong brand color: a green gradient pill, white + // label + icon, and a soft lifted shadow so it clearly stands out from the + // light rail around it. return { - root: `rounded-[10px] font-medium transition-all duration-150 bg-[#ECF6F1]! ring-1 ring-inset ring-[#0EA371]/15`, - label: `text-[#0A6F4D]! font-bold!`, - section: `text-[#0A6F4D]!`, + root: `rounded-[12px] font-medium transition-all duration-150 bg-gradient-to-r from-[#0EA371] to-[#0A8A60]! shadow-[0_6px_16px_rgba(14,163,113,0.30)]!`, + label: `text-white! font-bold!`, + section: `text-white!`, }; } return { - root: `rounded-[10px] font-medium transition-all duration-150 hover:bg-[#F1F4F7]!`, - label: `text-edr-text! font-semibold! hover:text-[#0C1A2B]!`, - section: `text-edr-text! hover:text-[#0C1A2B]!`, + root: `rounded-[12px] font-medium transition-all duration-150 hover:bg-[#EBF4EF]!`, + label: `text-edr-text! font-semibold! hover:text-[#0A6F4D]!`, + section: `text-[#64748B]! hover:text-[#0A6F4D]!`, }; }; @@ -253,9 +256,12 @@ export function AppLayout({ style={{ backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)", - border: "none", - boxShadow: "none", - background: "transparent", + // Light translucent surface with a faint green-tinted wash on the + // right, a hairline base, and a soft drop so it floats above content. + background: + "linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.78) 100%)", + borderBottom: `1px solid ${borderColor}`, + boxShadow: "0 1px 12px rgba(16,24,40,0.04)", }} > {item.section} @@ -795,7 +804,7 @@ export function AppLayout({ style={{ backgroundColor: bgColor, backgroundImage: - "radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.0.8) 38%, rgba(14,163,113,0.04) 72%)", + "radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.08) 38%, rgba(14,163,113,0.04) 72%)", backgroundRepeat: "no-repeat", backgroundAttachment: "fixed", }} diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index dce8aad63..fc9f57a58 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 23cc908a6..a07bd8492 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -9,6 +9,7 @@ import { HelloSection, InvoicesSection, RecentActivitySection, + RecentContractsSection, ShipmentsSection, StatsSection, } from "./components"; @@ -23,6 +24,9 @@ export default function MyPortalPage() { companyProfiles, bookingsQuery, dashboardQuery, + contractsQuery, + recentContracts, + activeContractsCount, allBookings, activeBookings, newActiveThisWeek, @@ -66,9 +70,11 @@ export default function MyPortalPage() { )} + {/* Contracts lead the dashboard; bookings live under their contract. */} - @@ -100,7 +106,25 @@ export default function MyPortalPage() { - + + + + + + + + + + + - - - - ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx index ae7aa6c02..39e401e63 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -1,5 +1,5 @@ import { Box, Group, Text } from "@mantine/core"; -import { ArrowRight, Truck } from "lucide-react"; +import { ArrowRight, FileSignature } from "lucide-react"; import { memo } from "react"; import { Link } from "react-router-dom"; import { cv } from "../constants"; @@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({ - + - + - Book a shipment + Create a contract diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx index 44a8e7fa6..9f025d111 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx @@ -24,7 +24,7 @@ export const RecentActivitySection = memo(function RecentActivitySection({ Recent Activity - + View all diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentContractsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentContractsSection.tsx new file mode 100644 index 000000000..4db54f3b1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentContractsSection.tsx @@ -0,0 +1,140 @@ +import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, FileSignature, Package, Plus } from "lucide-react"; +import type { Freight } from "@edr/types"; +import { ContractStatusBadge } from "@/pages/contracts/contract-ui"; +import { Card } from "./Card"; +import { EmptyState } from "./EmptyState"; + +const INK = "#10202F"; +const MUTED = "#6B7C8E"; +const BORDER = "#E6ECF2"; + +// Statuses where a Path A customer may book directly against the contract. +const BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"]; + +interface RecentContractsSectionProps { + contracts: Freight.IContract[]; + isLoading: boolean; +} + +export const RecentContractsSection = memo(function RecentContractsSection({ + contracts, + isLoading, +}: RecentContractsSectionProps) { + const navigate = useNavigate(); + + return ( + + + + + Recent Contracts + + + Your freight agreements — ship against them after signing + + + + + + {isLoading ? ( + + {[1, 2, 3, 4].map((i) => ( + + ))} + + ) : contracts.length === 0 ? ( + + ) : ( + + {contracts.map((c) => { + const isGeneral = c.contractKind === "GENERAL"; + const isContainer = c.freightType === "CONTAINER"; + const canSign = c.status === "CONTRACT_READY"; + const canBook = !c.customsClearingEnabled && BOOKABLE.includes(c.status); + return ( + navigate(`/contracts/${c.id}`)} + > + + + {c.reference} + + + {isGeneral ? "General" : "One-Time"} ·{" "} + {isContainer ? "Container" : "Bulk"} + + + + + + {canSign ? ( + + ) : canBook ? ( + + ) : ( + + )} + + + ); + })} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index dbd12546b..dae90842b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,15 +1,17 @@ import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; -import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; +import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react"; import { memo } from "react"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; interface StatsSectionProps { + activeContractsCount: number; activeBookingsLength: number; newActiveThisWeek: number; bookingsLoading: boolean; + contractsLoading: boolean; outstandingInvoicesLength: number; totalOutstanding: number; deliveredCount: string | undefined; @@ -20,9 +22,11 @@ interface StatsSectionProps { } export const StatsSection = memo(function StatsSection({ + activeContractsCount, activeBookingsLength, newActiveThisWeek, bookingsLoading, + contractsLoading, outstandingInvoicesLength, totalOutstanding, deliveredCount, @@ -37,9 +41,17 @@ export const StatsSection = memo(function StatsSection({ className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]" > + 0 ? `+${newActiveThisWeek} this week` : ""} loading={bookingsLoading} + divider /> + ACTIVE_CONTRACT_STATUSES.includes(c.status), + ).length; + const allBookings = bookingsQuery.data?.items ?? []; const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status), @@ -67,6 +90,10 @@ export function useMyPortalData(selectedProfileId?: string) { companyProfiles, bookingsQuery, dashboardQuery, + contractsQuery, + allContracts, + recentContracts, + activeContractsCount, allBookings, activeBookings, newActiveThisWeek, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx index 5ec72f48f..75fe65ec7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx @@ -1,5 +1,4 @@ import { Box, Group, Table, Text } from "@mantine/core"; -import { Boxes } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -22,10 +21,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) { return ( - - - Containers - + Containers {totalUnits} unit{totalUnits !== 1 ? "s" : ""} 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 index aa0abb384..f56e07349 100644 --- 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 @@ -52,52 +52,30 @@ export function ContractCard({ radius={16} px={22} py={20} - bg="#F1FAF6" - style={{ border: "1px solid #CFEBDD" }} + bg="white" + style={{ + border: "1px solid #E6ECF2", + boxShadow: "0 1px 2px rgba(16,24,40,0.04)", + }} > - - + - - - - - What's next - - - {c.title} - - - {c.description} - - - + What's next + + + {c.title} + + + {c.description} + + {c.buttonLabel && ( - ); - } - // CHANGES_REQUESTED + clearance/operation steps are handled in place by a - // modal (update & resubmit, upload clearance docs, schedule & proceed). - if (bookingHasInlineAction(booking)) { - return ; - } - const payableStatus = isGeneralContract - ? "FULLY_EXECUTED" - : "SELECTED_FOR_BATCH"; - if (status === payableStatus && booking.paymentStatus !== "PAID") { - return ; - } - return ( - - ); -} - -function ColHeader({ label }: { label: string }) { - return ( - - {label} - - ); -} - -const hMeta = { headerClassName: "bg-[#F4F7FA]" }; - -function fmtDate(iso?: string | null): string { - if (!iso) return ""; - const d = new Date(iso); - return Number.isNaN(d.getTime()) - ? "" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -// Lightweight count query for a single lifecycle filter (reads only `total`). -function useStatusCount(statuses: string | undefined): number | undefined { - const { data } = useQuery( - api.bookings.list.queryOptions({ - input: { statuses, page: 1, pageSize: 1 }, - staleTime: 30_000, - }), - ); - return data?.meta?.total; -} - -function StatCard({ - card, - active, - count, - onSelect, -}: { - card: (typeof STAT_CARDS)[number]; - active: boolean; - count: number | undefined; - onSelect: () => void; -}) { - const Icon = card.icon; - return ( - { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect(); - } - }} - p="md" - radius="lg" - withBorder - style={{ - cursor: "pointer", - transition: "box-shadow 140ms ease, border-color 140ms ease", - borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)", - boxShadow: active ? "0 0 0 1px #F2A516" : "none", - }} - > - - - - - - - {count ?? "—"} - - - {card.label} - - - - - ); -} - -export default function MyBookings() { - const navigate = useNavigate(); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [statusFilter, setStatusFilter] = useState("all"); - const [query, setQuery] = useState(""); - const [typeFilter, setTypeFilter] = useState(null); - const [freightFilter, setFreightFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(""); - const [createdTo, setCreatedTo] = useState(""); - const [trackingBooking, setTrackingBooking] = - useState(null); - - const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; - - const resetPage = () => - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - - const selectFilter = (key: StatusFilterKey) => { - setStatusFilter(key); - resetPage(); - }; - - const hasExtraFilters = - !!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; - const clearExtraFilters = () => { - setTypeFilter(null); - setFreightFilter(null); - setCreatedFrom(""); - setCreatedTo(""); - resetPage(); - }; - - const filter: BookingListFilter = useMemo( - () => ({ - statuses, - bookingType: typeFilter ?? undefined, - freightType: freightFilter ?? undefined, - createdFrom: createdFrom || undefined, - // include the whole selected end day - createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - }), - [ - statuses, - typeFilter, - freightFilter, - createdFrom, - createdTo, - pagination.pageIndex, - pagination.pageSize, - ], - ); - - const { data, isLoading, isError } = useQuery( - api.bookings.list.queryOptions({ input: filter }), - ); - - // Per-card lifecycle counts (one cheap query each, total-only). - const allCount = useStatusCount(undefined); - const activeCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "active")!.statuses, - ); - const paymentCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, - ); - const draftCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, - ); - const doneCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "done")!.statuses, - ); - const transitCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "transit")!.statuses, - ); - const closedCount = useStatusCount( - STATUS_FILTERS.find((f) => f.key === "closed")!.statuses, - ); - const cardCounts: Record = { - all: allCount, - active: activeCount, - payment: paymentCount, - draft: draftCount, - done: doneCount, - transit: transitCount, - closed: closedCount, - }; - - const allItems = data?.items ?? []; - const total = data?.meta?.total ?? allItems.length; - - // Server handles status + pagination; reference search is applied on the page. - const rows = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return allItems; - return allItems.filter((b) => - [b.reference, b.originYard?.label, b.destinationYard?.label] - .filter(Boolean) - .some((v) => String(v).toLowerCase().includes(q)), - ); - }, [allItems, query]); - - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; - const showEmpty = !isLoading && !isError && rows.length === 0; - - const columns: ColumnDef[] = [ - { - id: "booking", - size: 244, - meta: hMeta, - header: () => , - cell: ({ row }) => { - const b = row.original; - const cargoLabel = - b.freightType === "BULK" ? "Bulk cargo" : "Container"; - return ( - - - - - - - {b.reference} - - - {cargoLabel} - - - - ); - }, - }, - { - id: "type", - size: 150, - meta: hMeta, - header: () => , - cell: ({ row }) => , - }, - { - id: "cargo", - size: 168, - meta: hMeta, - header: () => , - cell: ({ row }) => , - }, - { - id: "route", - size: 196, - meta: hMeta, - header: () => , - cell: ({ row }) => { - const b = row.original; - const origin = b.originYard?.label ?? b.originYard?.code ?? "—"; - const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—"; - const sub = fmtDate(b.scheduledDate ?? b.createdAt); - return ( - - - {origin} → {dest} - - {sub && ( - - {sub} - - )} - - ); - }, - }, - { - id: "payment", - size: 130, - meta: hMeta, - header: () => , - cell: ({ row }) => , - }, - { - id: "scheduling", - size: 140, - meta: hMeta, - header: () => , - cell: ({ row }) => , - }, - { - id: "status", - size: 190, - meta: hMeta, - header: () => , - cell: ({ row }) => , - }, - { - id: "amount", - size: 140, - meta: hMeta, - header: () => , - cell: ({ row }) => { - const b = row.original as Freight.IBooking & { - totalAmount?: number; - amount?: number; - }; - const amount = b.totalAmount ?? b.amount ?? null; - if (!amount) { - return ( - - — - - ); - } - return ( - - ETB {amount.toLocaleString()} - - ); - }, - }, - { - id: "actions", - meta: hMeta, - header: () => null, - cell: ({ row }) => { - const booking = row.original; - const trackable = TRACKABLE_STATUSES.has(booking.status); - return ( - e.stopPropagation()} - > - {trackable && ( - - )} - - - - - - - - - navigate(`/bookings/${booking.id}`)}> - View details - - {trackable && ( - } - onClick={() => setTrackingBooking(booking)} - > - Track shipment - - )} - - - - ); - }, - }, - ]; - - return ( - - - {/* ── Page header ─────────────────────────────────────────────── */} - - - - - Bookings - - - - Track every cargo booking — from draft to delivery. - - - - - - {/* ── Summary stat cards ──────────────────────────────────────── */} - - {STAT_CARDS.map((card) => ( - selectFilter(card.key)} - /> - ))} - - - {/* ── Bookings table card ──────────────────────────────────────── */} - - - - } - value={query} - onChange={(e) => setQuery(e.currentTarget.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - radius="md" - style={{ flex: 1, minWidth: 200, maxWidth: 340 }} - /> - { - setTypeFilter(v); - resetPage(); - }} - clearable - radius="md" - comboboxProps={{ withinPortal: true }} - style={{ width: 170 }} - aria-label="Filter by booking type" - /> - ({ + value: o.value, + label: o.label, + }))} + value={field.value || null} + onChange={(v) => v && field.onChange(v)} + onBlur={field.onBlur} + error={fieldState.error?.message} + allowDeselect={false} + radius={10} + checkIconPosition="right" + comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }} + styles={fieldStyles} + /> + {selected && ( + + {selected.description} + + )} - )} - /> - + ); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index eb6d0f4e2..1dc68eaea 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -1,17 +1,15 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; -// Wizard steps for the contract creation flow. Mirrors the booking wizard but -// the cargo step collects SCOPE only (no quantities) and the route step collects -// a non-binding estimated shipment date (the binding date is at booking time). +// Wizard steps for the contract creation flow. Condensed to four steps: the +// pickers are dropdown selects so each step fits one screen without scrolling. +// Cargo collects SCOPE only (no quantities); the route step also collects a +// non-binding estimated shipment date (the binding date is at booking time). export const CONTRACT_STEPS = [ - { id: 0, label: "Operation Type", short: "Operation" }, - { id: 1, label: "Contract Type", short: "Contract" }, - { id: 2, label: "Service Type & Mile", short: "Service" }, - { id: 3, label: "Cargo Scope", short: "Cargo" }, - { id: 4, label: "Route", short: "Route" }, - { id: 6, label: "Documents", short: "Documents" }, - { id: 7, label: "Review & Submit", short: "Submit" }, + { id: 0, label: "Setup", short: "Setup" }, + { id: 1, label: "Cargo & Route", short: "Cargo & Route" }, + { id: 2, label: "Documents", short: "Documents" }, + { id: 3, label: "Review & Submit", short: "Review" }, ] as const; export const OPERATION_TYPES = [ @@ -23,6 +21,55 @@ export const OPERATION_TYPES = [ ] as const; export type OperationType = (typeof OPERATION_TYPES)[number]; +export const OPERATION_TYPE_OPTIONS: Array<{ + value: OperationType; + label: string; + description: string; +}> = [ + { + value: "import", + label: "Import", + description: "Cargo arriving into Ethiopia via Djibouti.", + }, + { + value: "export", + label: "Export", + description: "Cargo leaving Ethiopia bound for Djibouti.", + }, + { + value: "intercity", + label: "Intercity", + description: "Domestic movement between Ethiopian yards.", + }, + { + value: "import_ff", + label: "Import as Freight Forwarder", + description: "Import handled on behalf of a client.", + }, + { + value: "export_ff", + label: "Export as Freight Forwarder", + description: "Export handled on behalf of a client.", + }, +]; + +export const CONTRACT_KIND_OPTIONS: Array<{ + value: ContractKindOption; + label: string; + description: string; +}> = [ + { + value: "one_time", + label: "One-Time Contract", + description: "A single shipment cycle — one active booking at a time.", + }, + { + value: "general_contract", + label: "General Contract", + description: "Ship multiple times over the validity window across routes.", + }, +]; + export type ContractDocuments = Record; export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const; @@ -69,6 +116,8 @@ export const contractFormSchema = z .object({ enabled: z.boolean().default(false), pickUpAddress: z.string().default(""), + // Optional free-text exact location (gate, building, landmark…). + exactLocation: z.string().default(""), lat: z.number().nullable().default(null), lng: z.number().nullable().default(null), }) @@ -80,6 +129,8 @@ export const contractFormSchema = z .object({ enabled: z.boolean().default(false), deliveryAddress: z.string().default(""), + // Optional free-text exact location (gate, building, landmark…). + exactLocation: z.string().default(""), lat: z.number().nullable().default(null), lng: z.number().nullable().default(null), }) @@ -201,8 +252,8 @@ export const initialContractFormValues: DeepPartial = { serviceTypeId: "", paymentCurrency: "USD", - firstMile: { enabled: false, pickUpAddress: "", lat: null, lng: null }, - lastMile: { enabled: false, deliveryAddress: "", lat: null, lng: null }, + firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null }, + lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null }, equipmentReturn: "with_return", customsClearingEnabled: false, customsClearingAgent: "", @@ -228,9 +279,12 @@ export const contractStepFields: Record< number, Array> > = { - 0: ["operationType"], - 1: ["contractKind", "contractType", "previousContractRef"], - 2: [ + // Step 0 — Setup: operation, contract kind/type, service, currency, miles. + 0: [ + "operationType", + "contractKind", + "contractType", + "previousContractRef", "serviceTypeId", "paymentCurrency", "equipmentReturn", @@ -239,7 +293,8 @@ export const contractStepFields: Record< "firstMile", "lastMile", ], - 3: [ + // Step 1 — Cargo & Route: scope, sizes, flags, origin/destination, date. + 1: [ "cargoType", "enabledContainerSizes", "cargoCommodityId", @@ -247,13 +302,13 @@ export const contractStepFields: Record< "cargoFreeText", "isHazardous", "isRefrigerated", - ], - 4: [ "originYard", "destinationYard", "extraRoutes", "estimatedShipmentDate", ], - 6: ["documents"], - 7: ["notes"], + // Step 2 — Documents. + 2: ["documents"], + // Step 3 — Review & Submit. + 3: ["notes"], }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx index 6ca3d1a1f..49d04533a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx @@ -1,24 +1,12 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { - ArrowDownToLine, - ArrowUpFromLine, - PackageCheck, - PackageOpen, - Truck, -} from "lucide-react"; -import { Text } from "@mantine/core"; +import { Select, Text } from "@mantine/core"; import { ContractFormInputValues, + OPERATION_TYPE_OPTIONS, type ContractFormValues, type OperationType, } from "./schema"; -import { - AlertBox, - OptionCard, - OptionFieldError, - StepCard, - StepHeader, -} from "./shared"; +import { AlertBox, fieldStyles } from "./shared"; type ContractForm = UseFormReturn< ContractFormInputValues, @@ -26,56 +14,11 @@ type ContractForm = UseFormReturn< ContractFormValues >; -const OPTIONS: Array<{ - value: OperationType; - title: string; - description: string; - icon: React.ReactNode; - iconBg: string; - iconColor: string; -}> = [ - { - value: "import", - title: "Import", - description: "Cargo arriving into Ethiopia via Djibouti.", - icon: , - iconBg: "#ECF6F1", - iconColor: "#0A6F4D", - }, - { - value: "export", - title: "Export", - description: "Cargo leaving Ethiopia bound for Djibouti.", - icon: , - iconBg: "#EAF1FB", - iconColor: "#2E5B96", - }, - { - value: "intercity", - title: "Intercity", - description: "Domestic movement between Ethiopian yards.", - icon: , - iconBg: "#F1ECFB", - iconColor: "#6A40B8", - }, - { - value: "import_ff", - title: "Import as FF", - description: "Import handled on behalf of a client as a freight forwarder.", - icon: , - iconBg: "#ECF6F1", - iconColor: "#0A6F4D", - }, - { - value: "export_ff", - title: "Export as FF", - description: "Export handled on behalf of a client as a freight forwarder.", - icon: , - iconBg: "#EAF1FB", - iconColor: "#2E5B96", - }, -]; - +/** + * Operation type — a single dropdown. The available options reflect the + * operations the company is registered for; selecting one switches the active + * company profile so the rest of the wizard is stamped correctly. + */ export function Step0OperationType({ form, allowedOperations, @@ -85,14 +28,12 @@ export function Step0OperationType({ allowedOperations: OperationType[]; onSelect?: (op: OperationType) => void; }) { - return ( - - } - title="Operation Type" - description="Choose what this contract is for. The options available reflect the operations your company is registered for." - /> + const data = OPERATION_TYPE_OPTIONS.filter((opt) => + allowedOperations.includes(opt.value), + ).map((opt) => ({ value: opt.value, label: opt.label })); + return ( +
{allowedOperations.length === 0 && ( Your company has no operational profile yet. Complete onboarding to @@ -103,36 +44,44 @@ export function Step0OperationType({ ( -
-
- {OPTIONS.filter((opt) => - allowedOperations.includes(opt.value), - ).map((opt) => ( - { - field.onChange(opt.value); - onSelect?.(opt.value); - }} - /> - ))} + render={({ field, fieldState }) => { + const selected = OPERATION_TYPE_OPTIONS.find( + (o) => o.value === field.value, + ); + return ( +
+ ({ + value: o.value, + label: o.label, + }))} + value={field.value ?? "one_time"} + onChange={(v) => field.onChange(v ?? "one_time")} + allowDeselect={false} + radius={10} + checkIconPosition="right" + comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }} + styles={fieldStyles} + /> + {selected && ( + + {selected.description} + + )} +
+ ); + }} + /> - ( -
- } - iconBg="#ECF6F1" - iconColor="#0A6F4D" - title="One-Time Contract" - description="A single shipment cycle — one active booking at a time under this contract." - onClick={() => field.onChange("one_time")} - /> - } - iconBg="#F1ECFB" - iconColor="#6A40B8" - title="General Contract" - description="Ship multiple times over the validity window across one or more routes — no quantity cap." - onClick={() => field.onChange("general_contract")} - /> -
- )} - /> - - - - - New or Renewal - - - Start a fresh contract or renew an existing one to reuse its details. - - - ( -
-
- } - iconBg="#ECF6F1" - iconColor="#0A6F4D" - title="New Contract" - description="Create a fresh freight contract from scratch." - onClick={() => { - field.onChange("new"); - form.clearErrors(["contractType", "previousContractRef"]); + ( + v && field.onChange(v)} + onBlur={field.onBlur} + error={fieldState.error?.message} + allowDeselect={false} + radius={10} + checkIconPosition="right" + comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }} + styles={fieldStyles} + /> + {serviceType?.description && ( + + {serviceType.description} + + )}
- -
- )} - /> + )} + /> - + +
{showServiceSections && ( - + Trucking & customs options {includesFirstMile && ( @@ -124,6 +133,7 @@ export function Step2ServiceType({ { enabled: false, pickUpAddress: "", + exactLocation: "", lat: null, lng: null, }, @@ -133,12 +143,13 @@ export function Step2ServiceType({ }} > {firstMileEnabled && ( - + ( )} /> - + ( + ef.onChange(e.currentTarget.value)} + radius={10} + styles={fieldStyles} + /> + )} + /> + )} )} @@ -190,24 +215,23 @@ export function Step2ServiceType({ { enabled: false, deliveryAddress: "", + exactLocation: "", lat: null, lng: null, }, { shouldDirty: true, shouldValidate: true }, ); - form.setValue("equipmentReturn", "with_return", { - shouldDirty: true, - }); } }} > {lastMileEnabled && ( - + ( )} /> - + ( + ef.onChange(e.currentTarget.value)} + radius={10} + styles={fieldStyles} + /> + )} + /> + )} )} /> )} - {includesLastMile && lastMileEnabled && ( - ( - } - title="Equipment Return" - description={ - field.value === "with_return" - ? "Container returned to EDR after unloading." - : "Container retained by the customer after delivery." - } - checked={field.value === "with_return"} - onChange={(v) => - field.onChange(v ? "with_return" : "without_return") - } - /> - )} - /> - )} {includesCustoms ? ( )} - - ); -} - -function ServiceTypeCard({ - selected, - onClick, - title, - description, -}: { - selected: boolean; - onClick: () => void; - title?: ReactNode; - description?: ReactNode; -}) { - return ( - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 40349aa42..3d342046d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -1,21 +1,23 @@ import { useEffect, useMemo, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; -import { Flame, Package, Snowflake, Weight } from "lucide-react"; -import { Box, Group, Skeleton, Stack, Switch, Text } from "@mantine/core"; +import { Flame, Snowflake } from "lucide-react"; +import { Box, Group, MultiSelect, Select, Skeleton, Stack, Switch, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; import { - CONTAINER_SIZES, ContractFormInputValues, type ContractFormValues, } from "./schema"; -import { - OptionCard, - OptionFieldError, - SelectField, - StepCard, - StepHeader, - StepLabel, -} from "./shared"; +import { fieldStyles, SelectField, StepLabel } from "./shared"; + +const CONTAINER_SIZE_OPTIONS = [ + { value: "20ft", label: "20ft Container (TEU)" }, + { value: "40ft", label: "40ft Container (FEU)" }, +]; + +const CARGO_TYPE_OPTIONS = [ + { value: "container", label: "Containerized (20ft / 40ft)" }, + { value: "bulk", label: "General / Bulk cargo" }, +]; type ContractForm = UseFormReturn< ContractFormInputValues, @@ -99,121 +101,79 @@ export function Step3CargoScope({ if (isLoading) { return ( - - } - title="Cargo Scope" - description="Define what cargo this contract covers — sizes and commodity, no quantities." - /> -
- -
- - -
- -
-
+
+ + + +
); } return ( - - } - title="Cargo Scope" - description="Define what cargo this contract covers. Quantities, container numbers and weights are captured later at booking." - /> - -
- Cargo Type * + +
( -
-
- } - iconBg="#ECF6F1" - iconColor="#0A6F4D" - title="Containerized" - description="Containerized cargo (20ft / 40ft)." - onClick={() => { - field.onChange("container"); - form.setValue("cargoTypePath", [], { shouldDirty: true }); - }} - /> - } - iconBg="#FDF3E0" - iconColor="#C77F09" - title="General Cargo" - description="Bulk commodities or break-bulk cargo." - onClick={() => { - field.onChange("bulk"); - form.setValue("enabledContainerSizes", [], { - shouldDirty: true, - }); - }} - /> -
- -
+