mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +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,
|
WindowStatusPill,
|
||||||
} from "@/components/trainScheduling/batchVisuals";
|
} from "@/components/trainScheduling/batchVisuals";
|
||||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||||
|
import { BookingsManager } from "./BookingsManager";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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).
|
// Every booking on this schedule, flattened across windows + pending-contract,
|
||||||
const batchBookings = useMemo(() => {
|
// de-duplicated (a booking only appears once). Feeds the management table.
|
||||||
if (!data) return { awaitingPayment: [], expired: [] };
|
const allBookings = useMemo(() => {
|
||||||
const all = [
|
if (!data) return [] as BatchBoardBookingDetail[];
|
||||||
|
const merged = [
|
||||||
...data.windows.flatMap((w) => w.bookings),
|
...data.windows.flatMap((w) => w.bookings),
|
||||||
...data.pendingContract.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 {
|
return {
|
||||||
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
||||||
expired: all.filter((b) => b.state === "EXPIRED"),
|
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).
|
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||||
const dayGroups = useMemo(() => {
|
const dayGroups = useMemo(() => {
|
||||||
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : 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 */}
|
{/* Batch windows */}
|
||||||
<Paper
|
<Paper
|
||||||
radius="lg"
|
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,
|
PreviewSummary,
|
||||||
ScheduleWarningsAlert,
|
ScheduleWarningsAlert,
|
||||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
|
||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||||
@@ -137,26 +136,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
|
|
||||||
const containerUnits = previewResult?.containerUnits ?? [];
|
const containerUnits = previewResult?.containerUnits ?? [];
|
||||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||||
const hasContainerStep = useMemo(
|
// Container-number placement step removed — the customer enters container
|
||||||
() =>
|
// numbers when booking, so scheduling skips straight from the wagon plan to
|
||||||
shouldShowContainerPlacementStep({
|
// finalize. Steps: select bookings → review wagons → finalize.
|
||||||
containerUnitCount: containerUnits.length,
|
const hasContainerStep = false;
|
||||||
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,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayWagonPlan = useMemo(() => {
|
const displayWagonPlan = useMemo(() => {
|
||||||
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export function AppLayout({
|
|||||||
const mutedColor = theme.colors["edr-muted"][6];
|
const mutedColor = theme.colors["edr-muted"][6];
|
||||||
const textColor = theme.colors["edr-text"][6];
|
const textColor = theme.colors["edr-text"][6];
|
||||||
const accentColor = theme.colors["edr-accent"][6];
|
const accentColor = theme.colors["edr-accent"][6];
|
||||||
const bgColor = theme.colors["edr-bg"][6];
|
|
||||||
const primaryColor = theme.colors["edr-green"][5];
|
const primaryColor = theme.colors["edr-green"][5];
|
||||||
const primaryDarkColor = theme.colors["edr-green"][7];
|
const primaryDarkColor = theme.colors["edr-green"][7];
|
||||||
|
|
||||||
@@ -254,12 +253,9 @@ export function AppLayout({
|
|||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
style={{
|
style={{
|
||||||
backdropFilter: "blur(14px)",
|
// Solid white surface with a hairline base and a soft drop so it
|
||||||
WebkitBackdropFilter: "blur(14px)",
|
// floats above the content area.
|
||||||
// Light translucent surface with a faint green-tinted wash on the
|
background: "#FFFFFF",
|
||||||
// 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%)",
|
|
||||||
borderBottom: `1px solid ${borderColor}`,
|
borderBottom: `1px solid ${borderColor}`,
|
||||||
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
|
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
|
||||||
}}
|
}}
|
||||||
@@ -512,10 +508,8 @@ export function AppLayout({
|
|||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
style={{
|
style={{
|
||||||
// Soft light wash — a barely-there green tint at the top fading to
|
// Clean white rail.
|
||||||
// white, so the rail reads as its own clean surface.
|
background: "#FFFFFF",
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, #F4FAF7 0%, #FBFDFC 22%, #FFFFFF 100%)",
|
|
||||||
borderRight: `1px solid ${borderColor}`,
|
borderRight: `1px solid ${borderColor}`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -802,11 +796,7 @@ export function AppLayout({
|
|||||||
{/* ── Main ── */}
|
{/* ── Main ── */}
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: bgColor,
|
backgroundColor: "#F1F5F9",
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -16,17 +16,24 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CheckCircle2,
|
Eye,
|
||||||
|
FileSignature,
|
||||||
FileStack,
|
FileStack,
|
||||||
Inbox,
|
Inbox,
|
||||||
Package,
|
Package,
|
||||||
|
PackagePlus,
|
||||||
|
PencilLine,
|
||||||
Plus,
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
Timer,
|
Timer,
|
||||||
|
Upload,
|
||||||
Weight,
|
Weight,
|
||||||
X,
|
X,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
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 = [
|
const PATH_B_CLEARANCE_STATUSES = [
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
"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. */
|
/** The single most relevant next action for a customer's contract row. */
|
||||||
function getCustomerRowAction(
|
function getCustomerRowAction(
|
||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
bookings: Freight.IBooking[],
|
bookings: Freight.IBooking[],
|
||||||
): { label: string; to: string; primary: boolean } {
|
): RowAction {
|
||||||
const id = contract.id;
|
const id = contract.id;
|
||||||
if (contract.status === "CONTRACT_READY") {
|
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") {
|
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 (
|
if (
|
||||||
contract.customsClearingEnabled &&
|
contract.customsClearingEnabled &&
|
||||||
@@ -79,16 +105,27 @@ function getCustomerRowAction(
|
|||||||
label: "Upload clearance",
|
label: "Upload clearance",
|
||||||
to: `/contracts/${id}/clearance`,
|
to: `/contracts/${id}/clearance`,
|
||||||
primary: true,
|
primary: true,
|
||||||
|
icon: Upload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const booking = getContractBookingAction(contract, bookings);
|
const booking = getContractBookingAction(contract, bookings);
|
||||||
if (booking.kind === "book") {
|
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") {
|
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() {
|
export default function ContractsList() {
|
||||||
@@ -338,6 +375,7 @@ export default function ContractsList() {
|
|||||||
verticalSpacing={14}
|
verticalSpacing={14}
|
||||||
horizontalSpacing={20}
|
horizontalSpacing={20}
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
|
highlightOnHoverColor="#F4FBF8"
|
||||||
styles={{
|
styles={{
|
||||||
th: {
|
th: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -348,6 +386,12 @@ export default function ContractsList() {
|
|||||||
background: "#F8FAFC",
|
background: "#F8FAFC",
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
transition: "background-color 120ms ease",
|
||||||
},
|
},
|
||||||
td: {
|
td: {
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
@@ -507,16 +551,26 @@ export default function ContractsList() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
h={34}
|
||||||
variant={action.primary ? "filled" : "light"}
|
variant={action.primary ? "filled" : "light"}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
leftSection={<action.icon size={15} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(action.to);
|
navigate(action.to);
|
||||||
}}
|
}}
|
||||||
styles={{
|
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}
|
{action.label}
|
||||||
|
|||||||
@@ -112,17 +112,24 @@ export default function NewShipmentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path A guard — customs contracts are GL-booked, not customer-booked.
|
// Customs (Path B) contracts can only be booked once GL has finalized the
|
||||||
if (contract.customsClearingEnabled) {
|
// 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 (
|
return (
|
||||||
<Box p="xl">
|
<Box p="xl">
|
||||||
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
|
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
|
||||||
<Text fw={700} mb="xs">
|
<Text fw={700} mb="xs">
|
||||||
Bookings for this contract are handled by Global Logistics
|
Clearance not finalized yet
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mb="md">
|
<Text size="sm" mb="md">
|
||||||
This contract includes customs clearance. Upload your clearance
|
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>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
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. */
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
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 type ContractBookingActionKind = "book" | "rebook" | "none";
|
||||||
|
|
||||||
export interface ContractBookingAction {
|
export interface ContractBookingAction {
|
||||||
@@ -35,8 +45,10 @@ export function getContractBookingAction(
|
|||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
bookings: Freight.IBooking[],
|
bookings: Freight.IBooking[],
|
||||||
): ContractBookingAction {
|
): ContractBookingAction {
|
||||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
const bookable = contract.customsClearingEnabled
|
||||||
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
? 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`;
|
const to = `/contracts/${contract.id}/bookings/new`;
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export function ContractDocButton({
|
|||||||
const file = contractPdfFile(contract);
|
const file = contractPdfFile(contract);
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
return (
|
return (
|
||||||
<Tooltip label="Contract document">
|
<Tooltip label="Contract document" withArrow>
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="a"
|
||||||
href={fileViewUrl(file.id)}
|
href={fileViewUrl(file.id)}
|
||||||
@@ -266,12 +266,22 @@ export function ContractDocButton({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 32,
|
width: 34,
|
||||||
height: 32,
|
height: 34,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: `1px solid ${BORDER}`,
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: "#FFFFFF",
|
||||||
color: GREEN_DARK,
|
color: GREEN_DARK,
|
||||||
flexShrink: 0,
|
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} />
|
<FileText size={16} />
|
||||||
|
|||||||
Reference in New Issue
Block a user