add contract clearance detail page and enhance contract requests filtering and ui fix

This commit is contained in:
Marshal
2026-07-19 08:41:03 +00:00
parent 17dc505d50
commit 8816d50276
12 changed files with 619 additions and 132 deletions

View File

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

View File

@@ -4,6 +4,7 @@ import {
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -37,10 +38,12 @@ import {
FileDown,
FileText,
FileUp,
Flame,
MapPin,
Package,
Receipt,
Repeat,
Snowflake,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -302,13 +305,34 @@ export default function GlCreateBookingForm() {
*/
const handlingColumns = (
[
contract?.isHazardous && { key: "isHazardous", label: "Hazardous" },
contract?.isReefer && { key: "isReefer", label: "Refrigerated" },
contractWithReturn && { key: "isReturn", label: "With return" },
] as Array<false | undefined | { key: keyof UnitDraft; label: string }>
contract?.isHazardous && {
key: "isHazardous",
label: "Hazardous",
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<{
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() {
</Text>
) : null}
<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) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
@@ -1265,9 +1320,9 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
@@ -1277,11 +1332,11 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
@@ -1298,22 +1353,31 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{handlingColumns.map((col) => (
<Switch
<Box
key={col.key}
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
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",
}}
>
<Switch
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>
))}

View File

@@ -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,

View File

@@ -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() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: "Not found" },
]}
/>
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
meta={

View File

@@ -4,11 +4,14 @@ import {
Button,
Card,
Group,
MultiSelect,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -17,6 +20,7 @@ import {
CheckCircle2,
Clock,
FileText,
FilterX,
Inbox,
LayoutList,
RefreshCw,
@@ -36,7 +40,10 @@ import {
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
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 {
getStaffRowAction,
toContractListRow,
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
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 {
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<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 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() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={SORT_OPTIONS}
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>
{showEmpty ? (

View File

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

View File

@@ -28,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// 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 =
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
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);

View File

@@ -37,6 +37,11 @@ import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "./contract/ContractSignButton";
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
import {
BookingStatusBadge as StatusBadge,
BookingTypeBadge,
@@ -54,6 +59,7 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import "./bookings-table.css";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
@@ -200,6 +206,14 @@ function PrimaryAction({
if (status === payableStatus && booking.paymentStatus !== "PAID") {
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 (
<Button
size="xs"
@@ -886,7 +900,7 @@ export default function BookingsListPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
containerClassName="edr-bookings-table border-0 shadow-none rounded-none"
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>Route</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Customs</Table.Th>
<Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Valid Until</Table.Th>
@@ -413,7 +414,7 @@ export default function ContractsList() {
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
@@ -423,7 +424,7 @@ export default function ContractsList() {
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
@@ -435,7 +436,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Stack align="center" gap={8} py={48}>
<Inbox
size={26}
@@ -560,6 +561,15 @@ export default function ContractsList() {
{tradeLabel}
</Text>
</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>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
@@ -612,7 +622,7 @@ export default function ContractsList() {
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
colSpan={12}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />

View File

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

View File

@@ -2,35 +2,51 @@
* Scoped to .edr-contracts-table — every rule below is prefixed, so no other
* Mantine Table in the portal is affected.
*
* Column sizing: table-layout stays `auto`, so a column with short content
* (a currency code, a badge) keeps its natural narrow width. The cap only
* kicks in for columns whose content would otherwise push past it — those
* wrap onto extra lines instead of widening the table.
* Column sizing: table-layout stays `auto`. Every column sizes to its content
* with a 30px floor and no wrapping — when the columns together outgrow the
* viewport, the table widens and the wrapper's overflow-x takes over.
*/
/*
* 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 {
/* Single knob for the cap — raise this if the columns read too cramped. */
--edr-col-max: 60px;
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-contracts-table th,
.edr-contracts-table td {
max-width: var(--edr-col-max);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 40px;
}
/*
* The two fixed-purpose columns are exempt from the cap: the expander is a
* 28px icon button that must not wrap, and the action column holds two
* buttons side by side.
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label ("General" →
* "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 td:last-child:not([colspan]) {
max-width: none;
white-space: nowrap;
width: 1%;
}
/* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */
@@ -49,7 +65,7 @@
.edr-contracts-table td:last-child:not([colspan]) {
position: sticky;
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.
*/
.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;
}