diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 16cbd8507..ce90554a6 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -844,6 +844,16 @@ const App = () => { } /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} + contract?.isHazardous && { + key: "isHazardous", + label: "Hazardous", + icon: , + color: "#C0392B", + }, + contract?.isReefer && { + key: "isReefer", + label: "Refrigerated", + icon: , + color: "#2E5B96", + }, + contractWithReturn && { + key: "isReturn", + label: "With return", + icon: , + color: "#0A6F4D", + }, + ] as Array< + | false + | undefined + | { key: keyof UnitDraft; label: string; icon: ReactNode; color: string } + > ).filter(Boolean) as Array<{ key: "isHazardous" | "isReefer" | "isReturn"; label: string; + icon: ReactNode; + color: string; }>; // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. @@ -1246,10 +1270,41 @@ export default function GlCreateBookingForm() { ) : null} + {/* Header row — input labels + handling-service labels, + one aligned grid shared by every unit row below. + Same layout as the portal shipment form. */} + {line.units.length > 0 && ( + + + Container number * + + + Seal number + + + VGM (tons) * + + {handlingColumns.map((col) => ( + + + {col.icon} + + + {col.label} + + + ))} + + )} {line.units.map((unit, unitIdx) => ( - + @@ -1277,11 +1332,11 @@ export default function GlCreateBookingForm() { } radius={10} styles={fieldStyles} + style={{ flex: 1 }} /> {handlingColumns.map((col) => ( - - patchUnit(lineIdx, unitIdx, { - [col.key]: e.currentTarget.checked, - }) - } - label={unitIdx === 0 ? col.label : undefined} - labelPosition="right" - size="sm" - mt={unitIdx === 0 ? 26 : 6} - /> + style={{ + width: 96, + flexShrink: 0, + height: 42, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + + patchUnit(lineIdx, unitIdx, { + [col.key]: e.currentTarget.checked, + }) + } + size="sm" + /> + ))} ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 5b365bc9d..945d142df 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -452,7 +452,9 @@ export default function ClearanceDocumentsPage() { data={contractRows} status={tableStatus} onRowClick={(row) => - navigate(`/dashboard/contracts/clearance/${row.id}`) + navigate( + `/dashboard/contracts/clearance-documents/${row.id}`, + ) } pagination={{ pageIndex: pagination.pageIndex, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 6c8565d03..63dbb5d0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; import { Alert, Badge, @@ -52,9 +52,21 @@ import { export default function ContractClearanceDetailPage() { const { id } = useParams<{ id: string }>(); + const { pathname } = useLocation(); const { view, viewer } = useFileViewer(); const { user } = useAuth(); + // The same detail page serves two hubs: the GL "Document Clearance" list and + // the Operations "Clearance Documents" list. Point back-navigation at + // whichever hub the user came through. + const fromOpsHub = pathname.startsWith( + "/dashboard/contracts/clearance-documents", + ); + const hubHref = fromOpsHub + ? "/dashboard/contracts/clearance-documents" + : "/dashboard/contracts/clearance"; + const hubLabel = fromOpsHub ? "Clearance Documents" : "Document Clearance"; + const { data: contract, refetch: refetchContract } = useContractDetail(id); const { data: clearance, @@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() { @@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() { t.key === tab); + const statuses = match?.statuses?.length + ? match.statuses + : CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []); + return 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 = `${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" }, +]; + +/** Local start-of-day → ISO, for inclusive "from" date filters. */ +function startOfDayIso(d: Date): string { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x.toISOString(); +} + +/** Local end-of-day → ISO, for inclusive "to" date filters. */ +function endOfDayIso(d: Date): string { + const x = new Date(d); + x.setHours(23, 59, 59, 999); + return x.toISOString(); +} + function formatDate(value: string | null | undefined): string { if (!value) return "—"; const d = new Date(value); @@ -79,32 +143,86 @@ export default function ContractRequestsPage() { const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); const [activeTab, setActiveTab] = useState("all"); + // Filter controls (empty/null = "all"). + const [statusFilter, setStatusFilter] = useState([]); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState( + null, + ); + const [kindFilter, setKindFilter] = useState(null); + const [currencyFilter, setCurrencyFilter] = useState(null); + const [createdFrom, setCreatedFrom] = useState(null); + const [createdTo, setCreatedTo] = useState(null); + const [sort, setSort] = useState("createdAt:DESC"); const tabStatuses = getStatusesForTab(activeTab); + const statusOptions = useMemo( + () => getStatusOptionsForTab(activeTab), + [activeTab], + ); const resetPage = useCallback(() => { setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); }, [setPagination, pagination.pageSize]); - const filter: ContractListFilter = useMemo( - () => ({ + const filter: ContractListFilter = useMemo(() => { + const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; + return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, - sortBy: "createdAt", - sortOrder: "DESC", + sortBy, + sortOrder, tab: activeTab, // Server-side free-text search (contract reference, customer name). ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), - ...(tabStatuses ? { statuses: tabStatuses } : {}), - }), - [ - pagination.pageIndex, - pagination.pageSize, - activeTab, - tabStatuses, - debouncedQuery, - ], - ); + // Explicit status picks narrow within the tab; otherwise the tab's + // status group applies. + ...(statusFilter.length + ? { statuses: statusFilter.join(",") } + : tabStatuses + ? { statuses: tabStatuses } + : {}), + ...(directionFilter ? { tradeDirection: directionFilter } : {}), + ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), + ...(kindFilter ? { contractKind: kindFilter } : {}), + ...(currencyFilter ? { paymentCurrency: currencyFilter } : {}), + ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), + ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), + }; + }, [ + pagination.pageIndex, + pagination.pageSize, + activeTab, + tabStatuses, + debouncedQuery, + statusFilter, + directionFilter, + freightTypeFilter, + kindFilter, + currencyFilter, + createdFrom, + createdTo, + sort, + ]); + + const activeFilterCount = + (statusFilter.length ? 1 : 0) + + (directionFilter ? 1 : 0) + + (freightTypeFilter ? 1 : 0) + + (kindFilter ? 1 : 0) + + (currencyFilter ? 1 : 0) + + (createdFrom || createdTo ? 1 : 0); + + const clearFilters = useCallback(() => { + setStatusFilter([]); + setDirectionFilter(null); + setFreightTypeFilter(null); + setKindFilter(null); + setCurrencyFilter(null); + setCreatedFrom(null); + setCreatedTo(null); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }, [setPagination, pagination.pageSize]); const { data, isLoading, isError, refetch, isFetching } = useContractList(filter); @@ -341,6 +459,8 @@ export default function ContractRequestsPage() { active={activeTab} onChange={(tab) => { setActiveTab(tab); + // Status picks belong to the previous tab's option set — reset. + setStatusFilter([]); setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); }} counts={tabCounts} @@ -349,38 +469,159 @@ export default function ContractRequestsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + resetPage(); + }} + rightSection={ + query && ( + { + setQuery(""); + resetPage(); + }} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + { + setDirectionFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 150 }} + aria-label="Filter by trade direction" + /> + { + setKindFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 160 }} + aria-label="Filter by contract kind" + /> +