Merge pull request #806 from Tria-plc/freight_feature/usermanagement

add contract clearance detail page and enhance contract requests fil…
This commit is contained in:
marshal
2026-07-19 11:41:45 +03:00
committed by GitHub
12 changed files with 619 additions and 132 deletions

View File

@@ -844,6 +844,16 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="contracts/clearance-documents/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ContractClearanceDetailPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */} {/* GL (Path B) contract clearance review hub */}
<Route <Route
path="contracts/clearance" path="contracts/clearance"

View File

@@ -4,6 +4,7 @@ import {
useRef, useRef,
useState, useState,
type KeyboardEvent, type KeyboardEvent,
type ReactNode,
} from "react"; } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -37,10 +38,12 @@ import {
FileDown, FileDown,
FileText, FileText,
FileUp, FileUp,
Flame,
MapPin, MapPin,
Package, Package,
Receipt, Receipt,
Repeat, Repeat,
Snowflake,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -302,13 +305,34 @@ export default function GlCreateBookingForm() {
*/ */
const handlingColumns = ( const handlingColumns = (
[ [
contract?.isHazardous && { key: "isHazardous", label: "Hazardous" }, contract?.isHazardous && {
contract?.isReefer && { key: "isReefer", label: "Refrigerated" }, key: "isHazardous",
contractWithReturn && { key: "isReturn", label: "With return" }, label: "Hazardous",
] as Array<false | undefined | { key: keyof UnitDraft; label: string }> icon: <Flame size={14} />,
color: "#C0392B",
},
contract?.isReefer && {
key: "isReefer",
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
contractWithReturn && {
key: "isReturn",
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
] as Array<
| false
| undefined
| { key: keyof UnitDraft; label: string; icon: ReactNode; color: string }
>
).filter(Boolean) as Array<{ ).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn"; key: "isHazardous" | "isReefer" | "isReturn";
label: string; label: string;
icon: ReactNode;
color: string;
}>; }>;
// Intercity shipments ride a passing import/export train staff pick at // Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies. // finalize time — no shipment day is chosen and no window gate applies.
@@ -1246,10 +1270,41 @@ export default function GlCreateBookingForm() {
</Text> </Text>
) : null} ) : null}
<Stack gap={10} mt={8}> <Stack gap={10} mt={8}>
{/* 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 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{line.units.map((unit, unitIdx) => ( {line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start"> <Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
<TextInput <TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567" placeholder="e.g. MSCU1234567"
value={unit.containerNumber} value={unit.containerNumber}
error={ error={
@@ -1265,9 +1320,9 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
<TextInput <TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional" placeholder="Optional"
value={unit.sealNumber} value={unit.sealNumber}
onChange={(e) => onChange={(e) =>
@@ -1277,11 +1332,11 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
<TextInput <TextInput
type="number" type="number"
onKeyDown={blockNegative} onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5" placeholder="e.g. 24.5"
min={0} min={0}
step={0.01} step={0.01}
@@ -1298,22 +1353,31 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
{handlingColumns.map((col) => ( {handlingColumns.map((col) => (
<Switch <Box
key={col.key} key={col.key}
checked={Boolean(unit[col.key])} style={{
aria-label={`${col.label} — container ${unitIdx + 1}`} width: 96,
onChange={(e) => flexShrink: 0,
patchUnit(lineIdx, unitIdx, { height: 42,
[col.key]: e.currentTarget.checked, display: "flex",
}) alignItems: "center",
} justifyContent: "center",
label={unitIdx === 0 ? col.label : undefined} }}
labelPosition="right" >
size="sm" <Switch
mt={unitIdx === 0 ? 26 : 6} checked={Boolean(unit[col.key])}
/> aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
size="sm"
/>
</Box>
))} ))}
</Group> </Group>
))} ))}

View File

@@ -452,7 +452,9 @@ export default function ClearanceDocumentsPage() {
data={contractRows} data={contractRows}
status={tableStatus} status={tableStatus}
onRowClick={(row) => onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`) navigate(
`/dashboard/contracts/clearance-documents/${row.id}`,
)
} }
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { import {
Alert, Alert,
Badge, Badge,
@@ -52,9 +52,21 @@ import {
export default function ContractClearanceDetailPage() { export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { pathname } = useLocation();
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const { user } = useAuth(); 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: contract, refetch: refetchContract } = useContractDetail(id);
const { const {
data: clearance, data: clearance,
@@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Clearance not found" title="Clearance not found"
backTo="/dashboard/contracts/clearance" backTo={hubHref}
breadcrumbs={[ breadcrumbs={[
{ { label: hubLabel, href: hubHref },
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: "Not found" }, { label: "Not found" },
]} ]}
/> />
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title={reference} title={reference}
backTo="/dashboard/contracts/clearance" backTo={hubHref}
breadcrumbs={[ breadcrumbs={[
{ { label: hubLabel, href: hubHref },
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference }, { label: reference },
]} ]}
meta={ meta={

View File

@@ -4,11 +4,14 @@ import {
Button, Button,
Card, Card,
Group, Group,
MultiSelect,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
@@ -17,6 +20,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
FileText, FileText,
FilterX,
Inbox, Inbox,
LayoutList, LayoutList,
RefreshCw, RefreshCw,
@@ -36,7 +40,10 @@ import {
} from "@/components/contracts/ContractStatusTabs"; } from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config"; import {
CONTRACT_LIST_TABS,
CONTRACT_STATUS_STYLES,
} from "@/features/contracts/contract-status.config";
import { import {
getStaffRowAction, getStaffRowAction,
toContractListRow, toContractListRow,
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
return match.statuses.join(","); return match.statuses.join(",");
} }
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
function getStatusOptionsForTab(
tab: ContractStatusTabKey,
): { value: string; label: string }[] {
const match = CONTRACT_LIST_TABS.find((t) => 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 { function formatDate(value: string | null | undefined): string {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);
@@ -79,32 +143,86 @@ export default function ContractRequestsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all"); const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
const tabStatuses = getStatusesForTab(activeTab); const tabStatuses = getStatusesForTab(activeTab);
const statusOptions = useMemo(
() => getStatusOptionsForTab(activeTab),
[activeTab],
);
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, 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, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
sortBy: "createdAt", sortBy,
sortOrder: "DESC", sortOrder,
tab: activeTab, tab: activeTab,
// Server-side free-text search (contract reference, customer name). // Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}), // Explicit status picks narrow within the tab; otherwise the tab's
}), // status group applies.
[ ...(statusFilter.length
pagination.pageIndex, ? { statuses: statusFilter.join(",") }
pagination.pageSize, : tabStatuses
activeTab, ? { statuses: tabStatuses }
tabStatuses, : {}),
debouncedQuery, ...(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 } = const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter); useContractList(filter);
@@ -341,6 +459,8 @@ export default function ContractRequestsPage() {
active={activeTab} active={activeTab}
onChange={(tab) => { onChange={(tab) => {
setActiveTab(tab); setActiveTab(tab);
// Status picks belong to the previous tab's option set — reset.
setStatusFilter([]);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
counts={tabCounts} counts={tabCounts}
@@ -349,38 +469,159 @@ export default function ContractRequestsPage() {
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <Stack gap="sm">
<TextInput <Group justify="space-between" gap="md" wrap="wrap">
placeholder="Search reference or customer…" <TextInput
leftSection={<Search size={18} />} placeholder="Search reference or customer…"
value={query} leftSection={<Search size={18} />}
onChange={(e) => { value={query}
setQuery(e.target.value); onChange={(e) => {
resetPage(); setQuery(e.target.value);
}} resetPage();
rightSection={ }}
query && ( rightSection={
<ActionIcon query && (
size="sm" <ActionIcon
color="gray" size="sm"
radius="md" color="gray"
variant="transparent" radius="md"
onClick={() => { variant="transparent"
setQuery(""); onClick={() => {
resetPage(); setQuery("");
}} resetPage();
> }}
<X size={16} /> >
</ActionIcon> <X size={16} />
) </ActionIcon>
} )
style={{ flex: 1, minWidth: "200px" }} }
radius="lg" style={{ flex: 1, minWidth: "200px" }}
/> radius="lg"
<Text size="sm" c="dimmed"> />
{total} record{total !== 1 ? "s" : ""} <Select
</Text> data={SORT_OPTIONS}
</Group> value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="lg"
style={{ minWidth: 170 }}
aria-label="Sort contracts"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<MultiSelect
placeholder={
statusFilter.length ? undefined : "All statuses"
}
data={statusOptions}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Filter by status"
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
aria-label="Filter by trade direction"
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<Select
placeholder="All currencies"
data={CURRENCY_OPTIONS}
value={currencyFilter}
onChange={(v) => {
setCurrencyFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
</Stack>
</Box> </Box>
{showEmpty ? ( {showEmpty ? (

View File

@@ -16,6 +16,9 @@ export interface ContractListFilter {
tradeDirection?: string; tradeDirection?: string;
contractKind?: string; contractKind?: string;
paymentCurrency?: string; paymentCurrency?: string;
/** Created-at range (ISO strings, inclusive). */
createdFrom?: string;
createdTo?: string;
/** Server-side free-text search (contract reference, company name). */ /** Server-side free-text search (contract reference, company name). */
search?: string; search?: string;
page?: number; page?: number;
@@ -139,6 +142,8 @@ function buildListParams(filter?: ContractListFilter) {
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.contractKind) params.contractKind = filter.contractKind; if (filter.contractKind) params.contractKind = filter.contractKind;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
} }
return params; return params;
} }

View File

@@ -28,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal // Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page. // instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
const canPay = const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID"; booking.status === payableStatus && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place // Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row. // via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking); const hasInlineAction = bookingHasInlineAction(booking);

View File

@@ -37,6 +37,11 @@ import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton"; import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton"; import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction"; import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "./contract/ContractSignButton";
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
import { import {
BookingStatusBadge as StatusBadge, BookingStatusBadge as StatusBadge,
BookingTypeBadge, BookingTypeBadge,
@@ -54,6 +59,7 @@ import {
type ColumnDef, type ColumnDef,
usePagination, usePagination,
} from "@edr/ui-common"; } from "@edr/ui-common";
import "./bookings-table.css";
// Bookings that have left (or are leaving) the yard can be tracked live. // Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([ const TRACKABLE_STATUSES = new Set([
@@ -200,6 +206,14 @@ function PrimaryAction({
if (status === payableStatus && booking.paymentStatus !== "PAID") { if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />; return <PayNowButton booking={booking} />;
} }
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
}
// Delivered cargo with a handover awaiting the customer's signature.
if (booking.handoverAwaitingSignature) {
return <ApproveDeliveryButton bookingId={id} size="xs" stopPropagation />;
}
return ( return (
<Button <Button
size="xs" size="xs"
@@ -886,7 +900,7 @@ export default function BookingsListPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
containerClassName="border-0 shadow-none rounded-none" containerClassName="edr-bookings-table border-0 shadow-none rounded-none"
footer={DataTableFooter} footer={DataTableFooter}
/> />
)} )}

View File

@@ -0,0 +1,84 @@
/*
* Scoped to .edr-bookings-table — the DataTable container div on the bookings
* list only; no other DataTable is affected. Mirrors contracts-table.css:
* content-sized columns with a 40px floor, horizontal scroll when the table
* outgrows the card, and a sticky shadowed action column.
*/
.edr-bookings-table {
overflow-x: auto;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-bookings-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-bookings-table th,
.edr-bookings-table td {
min-width: 40px;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-bookings-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (error / empty state) span every column via colspan — leave
* their wrapping alone.
*/
.edr-bookings-table th,
.edr-bookings-table td:not([colspan]) {
white-space: nowrap;
}
/* Sticky header row. */
.edr-bookings-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's default column size — hence
* !important. `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-bookings-table th:last-child,
.edr-bookings-table td:last-child:not([colspan]) {
width: 1% !important;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-bookings-table td:last-child:not([colspan]) {
/* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-bookings-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-bookings-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -403,6 +403,7 @@ export default function ContractsList() {
<Table.Th>Cargo</Table.Th> <Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th> <Table.Th>Route</Table.Th>
<Table.Th>Trade</Table.Th> <Table.Th>Trade</Table.Th>
<Table.Th>Customs</Table.Th>
<Table.Th>Currency</Table.Th> <Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th> <Table.Th>Created</Table.Th>
<Table.Th>Valid Until</Table.Th> <Table.Th>Valid Until</Table.Th>
@@ -413,7 +414,7 @@ export default function ContractsList() {
<Table.Tbody> <Table.Tbody>
{isLoading && ( {isLoading && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={11}> <Table.Td colSpan={12}>
<Center py={48}> <Center py={48}>
<Loader color="edr-green" size="sm" /> <Loader color="edr-green" size="sm" />
</Center> </Center>
@@ -423,7 +424,7 @@ export default function ContractsList() {
{!isLoading && isError && ( {!isLoading && isError && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={11}> <Table.Td colSpan={12}>
<Center py={48}> <Center py={48}>
<Text fz={13} c="red"> <Text fz={13} c="red">
Failed to load contracts. Please try again. Failed to load contracts. Please try again.
@@ -435,7 +436,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && ( {!isLoading && !isError && rows.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={11}> <Table.Td colSpan={12}>
<Stack align="center" gap={8} py={48}> <Stack align="center" gap={8} py={48}>
<Inbox <Inbox
size={26} size={26}
@@ -560,6 +561,15 @@ export default function ContractsList() {
{tradeLabel} {tradeLabel}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td>
<Badge
variant="light"
color={c.customsClearingEnabled ? "edr-green" : "gray"}
radius="sm"
>
{c.customsClearingEnabled ? "With customs" : "Without"}
</Badge>
</Table.Td>
<Table.Td> <Table.Td>
<Text fz={13} style={{ color: INK }}> <Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"} {c.paymentCurrency ?? "—"}
@@ -612,7 +622,7 @@ export default function ContractsList() {
{isOpen && ( {isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}> <Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td <Table.Td
colSpan={11} colSpan={12}
style={{ padding: "6px 20px 18px" }} style={{ padding: "6px 20px 18px" }}
> >
<ContractStepBanner contract={c} /> <ContractStepBanner contract={c} />

View File

@@ -1670,8 +1670,39 @@ function ContainerLineEditor({
</Text> </Text>
) : null} ) : null}
<Stack gap={10} mt={8}> <Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels, one aligned
grid shared by every unit row below. */}
{Math.max(quantity, units.length) > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} grow align="flex-start"> <Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller <Controller
name={`containers.${index}.units.${u}.containerNumber`} name={`containers.${index}.units.${u}.containerNumber`}
control={form.control} control={form.control}
@@ -1681,11 +1712,11 @@ function ContainerLineEditor({
onChange={(e) => onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase()) field.onChange(e.currentTarget.value.toUpperCase())
} }
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567" placeholder="e.g. MSCU1234567"
error={fieldState.error?.message} error={fieldState.error?.message}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
)} )}
/> />
@@ -1695,10 +1726,10 @@ function ContainerLineEditor({
render={({ field }) => ( render={({ field }) => (
<TextInput <TextInput
{...field} {...field}
label={u === 0 ? "Seal number" : undefined}
placeholder="Optional" placeholder="Optional"
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
)} )}
/> />
@@ -1710,13 +1741,13 @@ function ContainerLineEditor({
{...field} {...field}
type="number" type="number"
onKeyDown={blockNegative} onKeyDown={blockNegative}
label={u === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5" placeholder="e.g. 24.5"
min={0} min={0}
step={0.01} step={0.01}
error={fieldState.error?.message} error={fieldState.error?.message}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
)} )}
/> />
@@ -1726,28 +1757,25 @@ function ContainerLineEditor({
name={`containers.${index}.units.${u}.${col.key}`} name={`containers.${index}.units.${u}.${col.key}`}
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<Switch <Box
checked={Boolean(field.value)} style={{
aria-label={`${col.label} — container ${u + 1}`} width: 96,
onChange={(e) => flexShrink: 0,
toggleUnitHandling(u, col.key, e.currentTarget.checked) height: 42,
} display: "flex",
label={ alignItems: "center",
u === 0 ? ( justifyContent: "center",
<Group gap={4} wrap="nowrap"> }}
<span style={{ color: col.color, display: "flex" }}> >
{col.icon} <Switch
</span> checked={Boolean(field.value)}
<Text fz={12} fw={600} c="#10202F"> aria-label={`${col.label} — container ${u + 1}`}
{col.label} onChange={(e) =>
</Text> toggleUnitHandling(u, col.key, e.currentTarget.checked)
</Group> }
) : undefined size="sm"
} />
labelPosition="right" </Box>
size="sm"
mt={u === 0 ? 26 : 6}
/>
)} )}
/> />
))} ))}

View File

@@ -2,35 +2,51 @@
* Scoped to .edr-contracts-table — every rule below is prefixed, so no other * Scoped to .edr-contracts-table — every rule below is prefixed, so no other
* Mantine Table in the portal is affected. * Mantine Table in the portal is affected.
* *
* Column sizing: table-layout stays `auto`, so a column with short content * Column sizing: table-layout stays `auto`. Every column sizes to its content
* (a currency code, a badge) keeps its natural narrow width. The cap only * with a 30px floor and no wrapping — when the columns together outgrow the
* kicks in for columns whose content would otherwise push past it — those * viewport, the table widens and the wrapper's overflow-x takes over.
* wrap onto extra lines instead of widening the table. */
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the overflow-x wrapper scrolls
* instead. min-width: 100% keeps it filling the card when content is narrow.
*/ */
.edr-contracts-table { .edr-contracts-table {
/* Single knob for the cap — raise this if the columns read too cramped. */ table-layout: auto;
--edr-col-max: 60px; width: max-content;
min-width: 100%;
} }
.edr-contracts-table th, .edr-contracts-table th,
.edr-contracts-table td { .edr-contracts-table td {
max-width: var(--edr-col-max); min-width: 40px;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
} }
/* /*
* The two fixed-purpose columns are exempt from the cap: the expander is a * Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* 28px icon button that must not wrap, and the action column holds two * cell that resolves against min-content and clips the label ("General" →
* buttons side by side. * "Gen…"). Let the badge size to its text so the column grows to fit it.
*/
.edr-contracts-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (loading / error / empty / expanded step banner) span every
* column via colspan — leave their wrapping alone.
*/
.edr-contracts-table th,
.edr-contracts-table td:not([colspan]) {
white-space: nowrap;
}
/*
* Action column hugs its content: width 1% + nowrap makes the browser give it
* the minimum width its buttons need and nothing more.
*/ */
.edr-contracts-table th:first-child,
.edr-contracts-table td:first-child:not([colspan]),
.edr-contracts-table th:last-child, .edr-contracts-table th:last-child,
.edr-contracts-table td:last-child:not([colspan]) { .edr-contracts-table td:last-child:not([colspan]) {
max-width: none; width: 1%;
white-space: nowrap;
} }
/* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */ /* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */
@@ -49,7 +65,7 @@
.edr-contracts-table td:last-child:not([colspan]) { .edr-contracts-table td:last-child:not([colspan]) {
position: sticky; position: sticky;
right: 0; right: 0;
box-shadow: -8px 0 8px -8px rgba(16, 32, 47, 0.18); box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
} }
/* /*
@@ -58,7 +74,8 @@
* the background the row already has. * the background the row already has.
*/ */
.edr-contracts-table td:last-child:not([colspan]) { .edr-contracts-table td:last-child:not([colspan]) {
background: #ffffff; /* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2; z-index: 2;
} }