mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add contract clearance detail page and enhance contract requests filtering and ui fix
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user