mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
implement BookingsManager component for managing batch bookings with search and filter functionality
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user