import { directionLabel } from "@/lib/utils"; import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { ActionIcon, Box, Card, Stack, Text, ThemeIcon, } from "@mantine/core"; import { AlertTriangle, ArrowRight, CalendarClock, CheckCircle2, Clock, FileText, Inbox, LayoutList, RefreshCw, Repeat, User, } from "lucide-react"; import { useCallback, useMemo, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { CONTRACT_LIST_TABS, CONTRACT_STATUS_STYLES, contractCourt, } from "@/features/contracts/contract-status.config"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; import { getStaffRowAction, toContractListRow, type ContractListRow, } from "@/features/contracts/mapContractListRow"; import { formatDate, humanize } from "@/lib/format"; import { useContractList, useContractListSummary, } from "@/hooks/contracts/useContracts"; import type { ContractListFilter } from "@/services/contracts.service"; import { Badge, DataTable, DataTableFooter, type ColumnDef, } from "@edr/ui-common"; import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; import { ExportButton } from "@/components/export/ExportButton"; /** Every filterable status — the pill tabs are gone, so the select carries them all. */ const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map( (s) => ({ value: s, label: CONTRACT_STATUS_STYLES[s]?.label ?? s, }), ); const TRADE_DIRECTION_OPTIONS = [ { value: "IMPORT", label: "Import" }, { value: "EXPORT", label: "Export" }, { value: "DOMESTIC", label: "Domestic" }, ]; const FREIGHT_TYPE_OPTIONS = [ { value: "CONTAINER", label: "Container" }, { value: "BULK", label: "Bulk" }, ]; const CONTRACT_KIND_OPTIONS = [ { value: "GENERAL", label: "General (recurring)" }, { value: "ONE_TIME", label: "One-time" }, ]; const CURRENCY_OPTIONS = [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, { value: "DJF", label: "DJF" }, ]; /** value = `${sortBy}:${sortOrder}` for the sort Select. */ const SORT_OPTIONS = [ { value: "createdAt:DESC", label: "Newest first" }, { value: "createdAt:ASC", label: "Oldest first" }, { value: "contractValidUntil:ASC", label: "Expiring soonest" }, { value: "contractValidUntil:DESC", label: "Expiring latest" }, ]; /** * Per-column widths — they must sum to the table's min-w (960px, set on the * containerClassName below) because table-fixed distributes any difference. */ const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 }; const COLUMN_META = { headerClassName: "whitespace-normal break-words", cellClassName: "whitespace-normal break-words align-top", }; export default function ContractRequestsPage() { const navigate = useNavigate(); const { filterOptions } = useMyTradeAccess(); // Yard options for the route filter (shared routes reference list, same // query BookingRequestsPage uses). const { data: yardRefs } = useQuery( api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), ); const yardOptions = useMemo( () => (yardRefs ?? []).map((y) => ({ value: y.id, label: y.label ?? y.code })), [yardRefs], ); // Service-type options — same reference-data payload the booking form uses. const { data: refData } = useQuery( api.bookings.referenceData.queryOptions({ staleTime: 5 * 60_000 }), ); const serviceTypeOptions = useMemo( () => (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name })), [refData], ); // Static shape only (no facet counts) — this is what useFilters needs to // parse the URL and build API params. Counts are attached separately below, // for rendering only, once the summary query (which itself depends on // these params) has resolved. const filterDefs: FilterDef[] = useMemo( () => [ { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "contractKind", label: "Kind", type: "enum", multiple: false, options: CONTRACT_KIND_OPTIONS, }, { key: "tradeDirection", label: "Direction", type: "enum", multiple: false, options: filterOptions(TRADE_DIRECTION_OPTIONS), }, { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, }, { key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions, }, { key: "paymentCurrency", label: "Currency", type: "enum", multiple: false, options: CURRENCY_OPTIONS, secondary: true, }, { key: "created", label: "Created", type: "date", secondary: true, // Before/after are safe to expose: the repository applies // createdFrom/createdTo independently, so a single-sided bound // already works server-side. operators: ["between", "before", "after"], toParams: dateRangeParams("createdFrom", "createdTo"), }, { key: "route", label: "Route", type: "route", options: yardOptions, toParams: routeParams("originYardId", "destinationYardId"), }, ], [filterOptions, yardOptions, serviceTypeOptions], ); const controls = useFilters(filterDefs, { defaultSort: "createdAt:DESC", pageSize: 10, }); const filter: ContractListFilter = useMemo( () => ({ ...(controls.params as unknown as ContractListFilter), // Kept as the React Query cache-key discriminator (tabs themselves are gone). tab: "all", }), [controls.params], ); const { data, isLoading, isError, refetch, isFetching } = useContractList(filter); const { data: summary, isLoading: summaryLoading, refetch: refetchSummary, } = useContractListSummary(filter); const statusCounts = useMemo( () => Object.fromEntries((summary?.facets?.status ?? []).map((b) => [b.value, b.count])), [summary?.facets], ); // filterDefs + counts, for the bar to render. Kept separate from filterDefs // itself so the URL-parsing hook above never has to wait on this query. const defs: FilterDef[] = useMemo( () => filterDefs.map((d) => d.key === "statuses" && d.type === "enum" ? { ...d, counts: statusCounts } : d, ), [filterDefs, statusCounts], ); const rows = useMemo( () => (data?.items ?? []).map(toContractListRow), [data?.items], ); const total = data?.total ?? 0; const showEmpty = !isLoading && !isError && rows.length === 0; const metrics = summary?.metrics; const tabCounts = summary?.tabs; const handleRefresh = useCallback(() => { void refetch(); void refetchSummary(); }, [refetch, refetchSummary]); const handleRowClick = useCallback( (row: ContractListRow) => { navigate(`/dashboard/contract-requests/${row.id}`); }, [navigate], ); const columns: ColumnDef[] = [ { id: "contract", size: COLUMN_WIDTHS.contract, meta: COLUMN_META, header: () => Customer, cell: ({ row }) => { const c = row.original; return (

{c.customerLabel}

{c.reference}

); }, }, { id: "route", size: COLUMN_WIDTHS.route, meta: COLUMN_META, header: () => Route, cell: ({ row }) => { const c = row.original; return (
{c.originLabel} {c.destinationLabel}
{directionLabel(c.tradeDirection)} {humanize(c.freightType)}
); }, }, { id: "status", size: COLUMN_WIDTHS.status, meta: COLUMN_META, header: () => Status, cell: ({ row }) => { const c = row.original; const court = contractCourt(c.status); const progress = formatContractApprovalProgress( c.status, c.approvalSteps, ); const action = getStaffRowAction(c); // One dimmed line: who it's waiting on, the staff verb, and the // approval chain when one exists. "Open" adds nothing — row click // already opens the detail page. const pieces: ReactNode[] = []; if (court) { pieces.push(court === "customer" ? "With customer" : "With EDR"); } if (action && action.variant === "filled") { pieces.push( // "View & sign" pointed at the contract-view page, not the // detail page — keep that deep link as an inline link. action.to(c.id).endsWith("/view") ? ( ) : ( action.label ), ); } if ((c.approvalSteps ?? []).length > 0) { pieces.push(progress.label); if (!progress.complete && progress.detail.startsWith("Next:")) { pieces.push(progress.detail); } } return (
{pieces.length ? (
{pieces.map((piece, i) => ( {i > 0 ? " · " : null} {piece} ))}
) : null}
); }, }, { id: "validity", size: COLUMN_WIDTHS.validity, meta: COLUMN_META, header: () => Validity, cell: ({ row }) => { const c = row.original; const isGeneral = c.contractKind === "GENERAL"; return ( {c.validUntil ? `Until ${formatDate(c.validUntil)}` : c.validityDays ? `${c.validityDays} days` : "—"} {c.validFrom ? ( From {formatDate(c.validFrom)} ) : null} {isGeneral ? ( General ) : ( "One-time" )} ); }, }, ]; return ( } /> {showEmpty ? ( No contracts match this view. ) : ( )} ); }