Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
marshal
2026-06-29 01:24:16 +03:00
8 changed files with 791 additions and 61 deletions

View File

@@ -55,6 +55,7 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
}),
);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [data]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
</Alert>
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Manage bookings</Title>
<Text size="sm" c="dimmed">
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated individually or in bulk.
</Text>
</Box>
</Group>
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
readOnly={bookingsReadOnly}
/>
</Paper>
{/* Batch windows */}
<Paper
radius="lg"

View File

@@ -0,0 +1,625 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
MoreVertical,
PackagePlus,
Search,
Trash2,
X,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green" },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
READY: { label: "Ready for batch", color: "teal" },
WAITING: { label: "Paid · waiting", color: "blue" },
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const STATE_FILTERS = [
{ value: "ALL", label: "All states" },
...Object.entries(STATE_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
const ALLOC_FILTERS = [
{ value: "ALL", label: "All allocations" },
...Object.entries(ALLOC_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
export interface BookingsManagerProps {
scheduleId: string;
bookings: BatchBoardBookingDetail[];
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
onChanged: () => void;
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
readOnly?: boolean;
}
/**
* Searchable, filterable, bulk-manageable booking table for the batch board.
* Staff can search by reference / customer, filter by batch state and wagon
* allocation status, and remove or re-assign bookings individually or in bulk.
* Wraps the shared DataTable; selection + actions are handled locally so the
* surrounding accordion / tab layout stays untouched.
*/
export function BookingsManager({
scheduleId,
bookings,
onChanged,
readOnly = false,
}: BookingsManagerProps) {
const { toast } = useToast();
const [query, setQuery] = useState("");
const [stateFilter, setStateFilter] = useState<string>("ALL");
const [allocFilter, setAllocFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<
| { kind: "remove"; ids: string[]; label: string }
| { kind: "reassign"; ids: string[]; label: string }
| null
>(null);
const unassign = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
);
const reassign = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const busy = unassign.isPending || reassign.isPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return bookings.filter((b) => {
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
return false;
if (!q) return true;
return (
b.reference.toLowerCase().includes(q) ||
b.company.toLowerCase().includes(q)
);
});
}, [bookings, query, stateFilter, allocFilter]);
// Selection is bounded to whatever is currently visible (filtered) to avoid
// acting on rows the user can't see.
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
const selectedVisible = useMemo(
() => visibleIds.filter((id) => selected.has(id)),
[visibleIds, selected],
);
const allVisibleSelected =
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
const someVisibleSelected =
selectedVisible.length > 0 && !allVisibleSelected;
const toggleAll = () =>
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const clearSelection = () => setSelected(new Set());
const runRemove = async (ids: string[]) => {
let ok = 0;
let failed = 0;
// Sequential — each unassign mutates the schedule graph; parallel would race.
for (const id of ids) {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Bookings removed",
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const runReassign = async (ids: string[]) => {
let ok = 0;
let failed = 0;
for (const id of ids) {
try {
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Re-assignment run",
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const confirmAction = async () => {
if (!confirm) return;
const ids = confirm.ids;
setConfirm(null);
if (confirm.kind === "remove") await runRemove(ids);
else await runReassign(ids);
};
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
if (!readOnly) {
cols.push({
id: "select",
meta: cellMeta,
header: () => (
<Checkbox
size="xs"
aria-label="Select all"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={toggleAll}
/>
),
cell: ({ row }) => (
<Checkbox
size="xs"
aria-label={`Select ${row.original.reference}`}
checked={selected.has(row.original.id)}
onChange={() => toggleOne(row.original.id)}
/>
),
});
}
cols.push(
{
id: "reference",
header: "Reference",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} c="dark.5">
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
);
},
},
{
id: "customer",
header: "Customer",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={8} wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 8,
flexShrink: 0,
background: "#FEF1D5",
border: "1px solid #FBD171",
}}
>
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)}
</Text>
</Box>
<Text size="sm" c="gray.7" truncate>
{b.company}
</Text>
</Group>
);
},
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt)
return (
<Text size="sm" c="dimmed">
</Text>
);
return (
<>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange.7" fw={600}>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
</Group>
);
},
},
{
id: "state",
header: "Batch state",
meta: cellMeta,
cell: ({ row }) => {
const m = STATE_META[row.original.state];
return (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
},
},
{
id: "allocation",
header: "Wagon allocation",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const m = ALLOC_META[b.allocationStatus];
const badge = (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
if (!b.allocationIssue) return badge;
return (
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
},
},
);
if (!readOnly) {
cols.push({
id: "actions",
header: "",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const isAssigned = b.allocationStatus === "ASSIGNED";
return (
<Group justify="flex-end" gap={4} wrap="nowrap">
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<PackagePlus size={14} />}
disabled={isAssigned || busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: [b.id],
label: b.reference,
})
}
>
Re-assign to wagons
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
disabled={!isAssigned || busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: [b.id],
label: b.reference,
})
}
>
Remove from train
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
});
}
return cols;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
readOnly,
selected,
allVisibleSelected,
someVisibleSelected,
visibleIds,
busy,
]);
return (
<Stack gap="sm">
{/* Toolbar: search + filters */}
<Group gap="sm" wrap="wrap">
<TextInput
flex={1}
miw={220}
radius="md"
placeholder="Search reference or customer…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
radius="md"
w={170}
data={STATE_FILTERS}
value={stateFilter}
onChange={(v) => setStateFilter(v ?? "ALL")}
aria-label="Filter by batch state"
/>
<Select
radius="md"
w={180}
data={ALLOC_FILTERS}
value={allocFilter}
onChange={(v) => setAllocFilter(v ?? "ALL")}
aria-label="Filter by allocation"
/>
<Text size="xs" c="dimmed">
{filtered.length} of {bookings.length}
</Text>
</Group>
{/* Bulk action bar */}
{!readOnly && selectedVisible.length > 0 ? (
<Paper
withBorder
radius="md"
px="md"
py="xs"
style={{
background: "var(--mantine-color-edr-green-0)",
borderColor: "var(--mantine-color-edr-green-2)",
}}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<Badge color="edr-green" radius="sm">
{selectedVisible.length} selected
</Badge>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={clearSelection}
>
Clear
</Button>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PackagePlus size={14} />}
loading={reassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Re-assign
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
loading={unassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Remove
</Button>
</Group>
</Group>
</Paper>
) : null}
<DataTable
columns={columns}
data={filtered}
status="success"
emptyMessage={
bookings.length
? "No bookings match the current search / filters."
: "No bookings in this batch window."
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
<Modal
opened={Boolean(confirm)}
onClose={() => setConfirm(null)}
centered
radius="md"
title={
confirm?.kind === "remove"
? "Remove from train"
: "Re-assign to wagons"
}
>
<Text size="sm" mb="lg">
{confirm?.kind === "remove"
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
: `Re-assign ${confirm?.label} to available wagons on this train?`}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button
color={confirm?.kind === "remove" ? "red" : "edr-green"}
loading={busy}
onClick={confirmAction}
>
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
</Button>
</Group>
</Modal>
</Stack>
);
}
export default BookingsManager;

View File

@@ -52,7 +52,6 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
@@ -137,26 +136,10 @@ export default function TrainScheduleV2DetailPage() {
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
// Container-number placement step removed — the customer enters container
// numbers when booking, so scheduling skips straight from the wagon plan to
// finalize. Steps: select bookings → review wagons → finalize.
const hasContainerStep = false;
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];

View File

@@ -160,7 +160,6 @@ export function AppLayout({
const mutedColor = theme.colors["edr-muted"][6];
const textColor = theme.colors["edr-text"][6];
const accentColor = theme.colors["edr-accent"][6];
const bgColor = theme.colors["edr-bg"][6];
const primaryColor = theme.colors["edr-green"][5];
const primaryDarkColor = theme.colors["edr-green"][7];
@@ -254,12 +253,9 @@ export function AppLayout({
<AppShell.Header
withBorder={false}
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
// Light translucent surface with a faint green-tinted wash on the
// right, a hairline base, and a soft drop so it floats above content.
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.78) 100%)",
// Solid white surface with a hairline base and a soft drop so it
// floats above the content area.
background: "#FFFFFF",
borderBottom: `1px solid ${borderColor}`,
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
}}
@@ -512,10 +508,8 @@ export function AppLayout({
<AppShell.Navbar
withBorder={false}
style={{
// Soft light wash — a barely-there green tint at the top fading to
// white, so the rail reads as its own clean surface.
background:
"linear-gradient(180deg, #F4FAF7 0%, #FBFDFC 22%, #FFFFFF 100%)",
// Clean white rail.
background: "#FFFFFF",
borderRight: `1px solid ${borderColor}`,
display: "flex",
flexDirection: "column",
@@ -802,11 +796,7 @@ export function AppLayout({
{/* ── Main ── */}
<AppShell.Main
style={{
backgroundColor: bgColor,
backgroundImage:
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.08) 38%, rgba(14,163,113,0.04) 72%)",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
backgroundColor: "#F1F5F9",
}}
>
{children}

View File

@@ -16,17 +16,24 @@ import {
Title,
} from "@mantine/core";
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
CheckCircle2,
Eye,
FileSignature,
FileStack,
Inbox,
Package,
PackagePlus,
PencilLine,
Plus,
RotateCcw,
Search,
Timer,
Upload,
Weight,
X,
type LucideIcon,
} from "lucide-react";
import { api } from "@/services/api";
@@ -53,23 +60,42 @@ function primaryRoute(contract: Freight.IContract) {
};
}
// Customs (Path B) statuses where the customer still needs to upload / manage
// clearance docs. Once finalized (CLEARANCE_READY_FOR_BOOKING) the row falls
// through to the Book action instead.
const PATH_B_CLEARANCE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
interface RowAction {
label: string;
to: string;
primary: boolean;
icon: LucideIcon;
}
/** The single most relevant next action for a customer's contract row. */
function getCustomerRowAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): { label: string; to: string; primary: boolean } {
): RowAction {
const id = contract.id;
if (contract.status === "CONTRACT_READY") {
return { label: "View & sign", to: `/contracts/${id}/view`, primary: true };
return {
label: "View & sign",
to: `/contracts/${id}/view`,
primary: true,
icon: FileSignature,
};
}
if (contract.status === "CHANGES_REQUESTED") {
return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true };
return {
label: "Edit & resubmit",
to: `/contracts/${id}`,
primary: true,
icon: PencilLine,
};
}
if (
contract.customsClearingEnabled &&
@@ -79,16 +105,27 @@ function getCustomerRowAction(
label: "Upload clearance",
to: `/contracts/${id}/clearance`,
primary: true,
icon: Upload,
};
}
const booking = getContractBookingAction(contract, bookings);
if (booking.kind === "book") {
return { label: "Book shipment", to: booking.to, primary: true };
return {
label: "Book shipment",
to: booking.to,
primary: true,
icon: PackagePlus,
};
}
if (booking.kind === "rebook") {
return { label: "Re-book shipment", to: booking.to, primary: true };
return {
label: "Re-book shipment",
to: booking.to,
primary: true,
icon: RotateCcw,
};
}
return { label: "View", to: `/contracts/${id}`, primary: false };
return { label: "View", to: `/contracts/${id}`, primary: false, icon: Eye };
}
export default function ContractsList() {
@@ -338,6 +375,7 @@ export default function ContractsList() {
verticalSpacing={14}
horizontalSpacing={20}
highlightOnHover
highlightOnHoverColor="#F4FBF8"
styles={{
th: {
fontSize: 11,
@@ -348,6 +386,12 @@ export default function ContractsList() {
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
position: "sticky",
top: 0,
zIndex: 1,
},
tr: {
transition: "background-color 120ms ease",
},
td: {
borderBottom: `1px solid ${BORDER}`,
@@ -507,16 +551,26 @@ export default function ContractsList() {
onClick={(e) => e.stopPropagation()}
/>
<Button
size="compact-sm"
size="sm"
radius="md"
h={34}
variant={action.primary ? "filled" : "light"}
color="edr-green"
leftSection={<action.icon size={15} />}
onClick={(e) => {
e.stopPropagation();
navigate(action.to);
}}
styles={{
root: { fontWeight: 600, paddingInline: 14 },
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap",
boxShadow: action.primary
? "0 1px 2px rgba(14,163,113,0.25)"
: "none",
},
}}
>
{action.label}

View File

@@ -112,17 +112,24 @@ export default function NewShipmentPage() {
);
}
// Path A guard — customs contracts are GL-booked, not customer-booked.
if (contract.customsClearingEnabled) {
// Customs (Path B) contracts can only be booked once GL has finalized the
// pre-booking clearance. Before that, send the customer to the clearance step.
// (GL "create booking" was removed — the customer books once cleared.)
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (contract.customsClearingEnabled && !clearanceFinalized) {
return (
<Box p="xl">
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
<Text fw={700} mb="xs">
Bookings for this contract are handled by Global Logistics
Clearance not finalized yet
</Text>
<Text size="sm" mb="md">
This contract includes customs clearance. Upload your clearance
documents and Global Logistics will create the booking for you.
documents once Global Logistics finalizes the clearance you can
create your shipment booking here.
</Text>
<Button
color="edr-green"

View File

@@ -15,6 +15,16 @@ export const TERMINAL_BOOKING_STATUSES = [
/** Path A statuses where a customer (no customs) may book against the contract. */
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
/**
* Path B (customs): the customer books once GL has finalized the pre-booking
* clearance. ACTIVE_SHIPMENT_IN_PROGRESS is included so GENERAL contracts can
* re-book after a prior shipment. (GL "create booking" was removed.)
*/
const PATH_B_BOOKABLE = [
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
export type ContractBookingActionKind = "book" | "rebook" | "none";
export interface ContractBookingAction {
@@ -35,8 +45,10 @@ export function getContractBookingAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): ContractBookingAction {
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
const bookable = contract.customsClearingEnabled
? PATH_B_BOOKABLE.includes(contract.status)
: PATH_A_BOOKABLE.includes(contract.status);
if (!bookable) return { kind: "none", to: "" };
const to = `/contracts/${contract.id}/bookings/new`;

View File

@@ -254,7 +254,7 @@ export function ContractDocButton({
const file = contractPdfFile(contract);
if (!file) return null;
return (
<Tooltip label="Contract document">
<Tooltip label="Contract document" withArrow>
<Box
component="a"
href={fileViewUrl(file.id)}
@@ -266,12 +266,22 @@ export function ContractDocButton({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
width: 34,
height: 34,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
color: GREEN_DARK,
flexShrink: 0,
transition: "border-color 120ms ease, background-color 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = GREEN;
e.currentTarget.style.backgroundColor = "#F0FAF5";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = BORDER;
e.currentTarget.style.backgroundColor = "#FFFFFF";
}}
>
<FileText size={16} />