Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-03 23:33:12 +00:00
66 changed files with 3174 additions and 536 deletions

View File

@@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
@@ -181,12 +182,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
// Hidden for now — Shipment Requests nav item disabled.
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
@@ -677,6 +679,7 @@ const App = () => {
</RequirePermission>
}
/>
{/* Hidden for now — Shipment Requests pages disabled.
<Route
path="shipment-requests"
element={
@@ -697,6 +700,7 @@ const App = () => {
</RequirePermission>
}
/>
*/}
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"

View File

@@ -1,5 +1,5 @@
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { Zap, Clock } from "lucide-react";
import { Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
/** Detail-page actions: primary staff-action toolbar. */
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
return null;
}
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && (
<SectionCard icon={FileText} title="Documents">
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
Download contract
</Button>
</SectionCard>
)}
</Stack>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
export interface BookingContainerUnitsCardProps {
booking: BookingDetail;
}
interface FlatUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
typeLabel: string;
sizeFt?: number;
}
/**
* The physical container manifest: one row per container with its number, type,
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
* bookings — when a line has no units the card falls back to the aggregate
* type/qty/weight so it still renders something for plain bookings.
*/
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
const lines = booking.bookingContainers ?? [];
const units: FlatUnit[] = useMemo(
() =>
lines.flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber,
vgmTons: Number(u.vgmTons) || 0,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
sizeFt: line.containerType?.sizeFt,
})),
),
[lines],
);
// Container bookings only — bulk has no container manifest.
if (booking.freightType === "BULK" || lines.length === 0) return null;
const totalUnits = units.length;
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
return (
<SectionCard
icon={Boxes}
title="Containers"
subtitle={
totalUnits > 0
? "Each physical container with its number and weight"
: "Per-container numbers were not captured for this booking"
}
accent="teal"
extra={
totalUnits > 0 ? (
<Badge color="teal" variant="light" radius="sm">
{totalUnits} container{totalUnits === 1 ? "" : "s"}
</Badge>
) : (
<Badge color="gray" variant="light" radius="sm">
{lines.length} line{lines.length === 1 ? "" : "s"}
</Badge>
)
}
>
{totalUnits > 0 ? (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 40 }}>#</Table.Th>
<Table.Th>Container No.</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">Weight (VGM)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u, i) => (
<Table.Tr key={u.id}>
<Table.Td>
<Text size="sm" c="dimmed">
{i + 1}
</Text>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={26} radius="md" variant="light" color="teal">
<ContainerIcon size={15} />
</ThemeIcon>
<Text size="sm" fw={700} ff="monospace">
{u.containerNumber}
</Text>
{u.isReefer ? (
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
<Snowflake size={12} />
</ThemeIcon>
) : null}
{u.isHazardous ? (
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
<Flame size={12} />
</ThemeIcon>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm">{u.typeLabel}</Text>
{u.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{u.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
{u.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{u.vgmTons.toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
pt="sm"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="sm" fw={600} c="dimmed">
Total weight (VGM)
</Text>
<Text size="sm" fw={800} c="teal.7">
{totalVgm.toFixed(3)} t
</Text>
</Group>
</Stack>
) : (
// Fallback: no per-unit numbers — show the aggregate lines.
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
<Table.Th ta="right">Total VGM</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => {
const perUnit = Number(line.vgmPerUnitTons) || 0;
return (
<Table.Tr key={line.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{line.containerType?.label ?? line.containerType?.code ?? "—"}
</Text>
{line.containerType?.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{line.containerType.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>{line.quantity}</Table.Td>
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{(line.quantity * perUnit).toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}

View File

@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";

View File

@@ -58,6 +58,25 @@ import {
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
@@ -106,6 +125,33 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
@@ -314,12 +360,13 @@ export default function GlCreateBookingForm() {
);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract) return;
if (!scheduledDate || !contract || !windowOpen) return;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -451,6 +498,33 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
@@ -846,6 +920,8 @@ export default function GlCreateBookingForm() {
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}

View File

@@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null {
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Card heading. Defaults to "Documents". */
title?: string;
/** Message shown when there are no files. */
emptyText?: string;
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
@@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps {
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
title = "Documents",
emptyText = "No documents attached to this contract.",
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
title={title}
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
{emptyText}
</Text>
) : (
<Stack gap="xs">

View File

@@ -0,0 +1,123 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Group, NumberInput, Select, Stack } from "@mantine/core";
export type DurationUnit = "minutes" | "hours" | "days";
const UNIT_MINUTES: Record<DurationUnit, number> = {
minutes: 1,
hours: 60,
days: 1440,
};
const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
{ value: "minutes", label: "min" },
{ value: "hours", label: "hr" },
{ value: "days", label: "day" },
];
/** Convert a value expressed in `from` units to `to` units. */
function convert(value: number, from: DurationUnit, to: DurationUnit): number {
return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to];
}
/** Pick the largest unit that keeps a value a clean-ish whole number, so a
* stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */
function bestDisplayUnit(minutes: number): DurationUnit {
if (minutes <= 0) return "minutes";
if (minutes % 1440 === 0) return "days";
if (minutes % 60 === 0) return "hours";
return "minutes";
}
export interface DurationFieldProps {
label: string;
description?: string;
/** Current value, expressed in `nativeUnit` (what the API/DB stores). */
value: number | string;
/** The unit the parent stores/sends. The field converts to this on change. */
nativeUnit: DurationUnit;
/** Called with the value converted back to `nativeUnit` (or "" when blank). */
onChange: (nativeValue: number | "") => void;
/** Smallest allowed value, in `nativeUnit`. */
min?: number;
disabled?: boolean;
}
export default function DurationField({
label,
description,
value,
nativeUnit,
onChange,
min,
disabled,
}: DurationFieldProps) {
const nativeMinutes = useMemo(() => {
const num = value === "" || value == null ? NaN : Number(value);
return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN;
}, [value, nativeUnit]);
// Display unit is user-driven; seed it from the incoming value once.
const [unit, setUnit] = useState<DurationUnit>(() =>
Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit,
);
// The value usually arrives async (after the initial "" render), so the
// useState seed above runs before it exists. Re-pick the friendliest display
// unit the first time a real value shows up — but never again, so the user's
// manual unit choice sticks.
const seeded = useRef(false);
useEffect(() => {
if (!seeded.current && Number.isFinite(nativeMinutes)) {
seeded.current = true;
setUnit(bestDisplayUnit(nativeMinutes));
}
}, [nativeMinutes]);
const displayValue: number | "" = Number.isFinite(nativeMinutes)
? Number(convert(nativeMinutes, "minutes", unit).toFixed(4))
: "";
const emitNative = (display: number | "", displayUnit: DurationUnit) => {
if (display === "" || !Number.isFinite(Number(display))) {
onChange("");
return;
}
const native = convert(Number(display), displayUnit, nativeUnit);
onChange(Number(native.toFixed(6)));
};
return (
<Stack gap={4}>
<Group gap="xs" align="flex-end" wrap="nowrap">
<NumberInput
label={label}
description={description}
value={displayValue}
onChange={(v) =>
emitNative(v === "" ? "" : Number(v), unit)
}
clampBehavior="none"
allowDecimal
min={min != null ? convert(min, nativeUnit, unit) : 0}
disabled={disabled}
style={{ flex: 1 }}
/>
<Select
aria-label={`${label} unit`}
data={UNIT_OPTIONS}
value={unit}
onChange={(next) => {
if (!next) return;
// Only the display unit changes; the stored native value stays put.
// displayValue re-derives from it on the next render.
setUnit(next as DurationUnit);
}}
allowDeselect={false}
disabled={disabled}
w={90}
/>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,593 @@
import { useMemo, useState } from "react";
import {
Badge,
Box,
Button,
Group,
Modal,
Paper,
Progress,
ScrollArea,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeftRight,
ArrowRight,
CheckCircle2,
Inbox,
PackageCheck,
Repeat,
Train,
Weight,
X,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
} from "@/types/trainScheduling";
interface ScheduleWorkspacePanelProps {
schedule: TrainScheduleDetail;
/** Refetch the schedule detail after a mutation so both panels refresh. */
onChanged: () => void;
}
const GREEN = "var(--mantine-color-edr-green-6)";
/**
* Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
): { label: string; deadline: string } | null {
switch (schedule.windowPhase) {
case "OPEN":
return schedule.windowClosesAt
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
: null;
default:
return null;
}
}
/** Cargo weight already allocated to this train (sum of on-train bookings). */
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),
0,
);
}
/** Max pull weight across all locomotives on the set (0 when unknown). */
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;
if (!set) return 0;
const locos =
set.locomotives && set.locomotives.length > 0
? set.locomotives
: set.locomotive
? [set.locomotive]
: [];
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
}
export function ScheduleWorkspacePanel({
schedule,
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
? schedule.freightType
: undefined;
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
// yet linked to any schedule (same filter the auto-batch uses).
const poolQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: {
filters: {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
trainScheduleId: schedule.id,
},
freightType,
},
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
}),
);
const onTrainIds = useMemo(
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
[schedule.bookings],
);
const pool: EligibleContainerBooking[] = useMemo(
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
[poolQuery.data, onTrainIds],
);
const onTrain = schedule.bookings ?? [];
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
originYardId: schedule.originStation?.id,
destinationYardId: schedule.destinationStation?.id,
},
enabled: Boolean(
schedule.originStation?.id && schedule.destinationStation?.id,
),
}),
);
const moveOptions = useMemo(
() =>
(targets ?? [])
.filter((s) => s.id !== schedule.id)
.map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
})),
[targets, schedule.id],
);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
.mutateAsync({
id: schedule.id,
freightType,
payload: {
bookingIds: [...onTrainIds, bookingId],
forceAssign: true,
},
})
.then(() => {
toast({
title: `${ref} added to train`,
description: wouldOverfill
? "Force-added past the pull-weight limit — review capacity."
: "Wagons auto-pinned.",
variant: wouldOverfill ? "destructive" : undefined,
});
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not add booking", variant: "destructive" }),
);
};
const removeFromTrain = (bookingId: string, ref: string) => {
unassign
.mutateAsync({ id: schedule.id, bookingId })
.then(() => {
toast({ title: `${ref} removed from train` });
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not remove booking", variant: "destructive" }),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
.then(() => {
toast({ title: "Booking reassigned to another train" });
setMoveBookingId(null);
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not reassign booking", variant: "destructive" }),
);
};
return (
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Header + capacity meter */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<PackageCheck size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
</Text>
</div>
</Group>
<Box miw={240} style={{ flex: "0 1 320px" }}>
<Group justify="space-between" mb={4} gap={4}>
<Group gap={6} align="center">
<Weight size={14} color={over ? "#B42318" : undefined} />
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
</Text>
</Group>
{over ? (
<Badge color="red" variant="light" size="sm" radius="sm">
Over capacity
</Badge>
) : (
<Text size="xs" c="dimmed">
{capacity > 0 ? `${pct}%` : "—"}
</Text>
)}
</Group>
<Progress
value={capacity > 0 ? pct : 0}
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
radius="xl"
size="md"
/>
</Box>
</Group>
{(() => {
const cd = phaseCountdown(schedule);
return cd ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
</Group>
) : null;
})()}
{over ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This train is loaded beyond its locomotive pull weight. Force-adds are
allowed, but review before dispatch.
</Text>
</Group>
) : null}
{locked ? (
<Text size="sm" c="dimmed">
This train is {schedule.status.toLowerCase()} bookings can no longer be
changed.
</Text>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
key={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
) : null
}
/>
))}
</PanelColumn>
{/* On train */}
<PanelColumn
title="On this train"
hint="Allocated bookings"
count={onTrain.length}
accent="#0EA371"
emptyIcon={Train}
emptyText="No bookings allocated yet. Add one from the pool."
>
{onTrain.map((b) => (
<BookingCard
key={b.id}
reference={b.reference ?? b.id.slice(0, 8)}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
variant="subtle"
color="orange"
radius="md"
leftSection={<Repeat size={13} />}
onClick={() => {
setMoveBookingId(b.id);
setMoveTarget(null);
}}
>
Move
</Button>
</Tooltip>
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<X size={13} />}
loading={unassign.isPending}
onClick={() =>
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
}
>
Remove
</Button>
</Tooltip>
</Group>
) : null
}
/>
))}
</PanelColumn>
</Group>
</Stack>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
onClose={() => setMoveBookingId(null)}
title={
<Group gap={8}>
<ArrowLeftRight size={18} />
<Text fw={700}>Reassign booking to another train</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Select
label="Target train (same route, open window)"
placeholder="Select an open schedule"
data={moveOptions}
value={moveTarget}
onChange={setMoveTarget}
searchable
nothingFoundMessage="No other open schedules on this route"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMoveBookingId(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!moveTarget}
loading={moveSchedule.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doMove}
>
Reassign
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function PanelColumn({
title,
hint,
count,
accent,
loading,
emptyIcon: EmptyIcon,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
accent: string;
loading?: boolean;
emptyIcon: typeof Inbox;
emptyText: string;
children: React.ReactNode;
}) {
const isEmpty = !loading && count === 0;
return (
<Paper
radius="lg"
withBorder
p="md"
miw={280}
style={{
flex: 1,
borderColor: "var(--mantine-color-gray-2)",
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Group gap={8} align="center">
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
<Text fw={700} size="sm">
{title}
</Text>
<Badge variant="light" color="gray" radius="sm" size="sm">
{count}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Group>
{isEmpty ? (
<Stack align="center" gap={6} py={32}>
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="dimmed" ta="center" maw={220}>
{emptyText}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={420} type="hover">
<Stack gap={8} pr={4}>
{loading ? (
<Text size="xs" c="dimmed" py="md" ta="center">
Loading
</Text>
) : (
children
)}
</Stack>
</ScrollArea.Autosize>
)}
</Paper>
);
}
function BookingCard({
reference,
customer,
weightTons,
status,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
right?: React.ReactNode;
}) {
return (
<Paper
radius="md"
withBorder
p="sm"
style={{
borderColor: "var(--mantine-color-gray-2)",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = GREEN;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{customer ?? "—"}
</Text>
{weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
</Group>
</Paper>
);
}

View File

@@ -286,6 +286,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
FileSignature,
MessageSquareWarning,
Play,
ShieldCheck,
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
primary: true,
};
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
@@ -340,22 +316,14 @@ export function getBookingActions(
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
actions = [CANCEL_ACTION];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
// Contract view/sign/executed buttons intentionally removed from the
// booking-request page.
actions = [];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import toast from 'react-hot-toast';
interface ToastOptions {
@@ -8,7 +9,9 @@ interface ToastOptions {
}
export function useToast() {
const showToast = (options: ToastOptions) => {
// Stable identity so callers can safely list `toast` in effect/callback deps
// without re-firing on every render.
const showToast = useCallback((options: ToastOptions) => {
const { title, description, variant = 'default', duration = 3000 } = options;
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
@@ -18,7 +21,7 @@ export function useToast() {
} else {
toast.success(message, { duration });
}
};
}, []);
return { toast: showToast };
}

View File

@@ -1,7 +1,6 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Milestone,
@@ -36,31 +35,19 @@ import {
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<PageContainer>
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
@@ -351,15 +301,10 @@ function OverviewPanel({
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}

View File

@@ -4,6 +4,7 @@ import {
Button,
Card,
Group,
Select,
Stack,
Tabs,
Text,
@@ -30,17 +31,12 @@ import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import {
useBookingDetail,
@@ -57,11 +53,29 @@ import {
type ColumnDef,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
if (!match?.statuses?.length) return undefined;
return match.statuses.join(",");
}
/** The two booking-kind tabs: one-time vs general-contract bookings. */
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
{ value: "ONE_TIME", label: "One-time booking" },
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
);
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
@@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string {
});
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter selects (each nullable = "all").
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
@@ -93,47 +109,26 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const isOperationsTab = activeTab === "operations";
const filter: BookingListFilter = useMemo(() => {
if (isOperationsTab) {
if (operationsSubTab === "ready") {
return {
page: 1,
pageSize: 100,
statuses: "PAID",
assignedToSchedule: "false",
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
};
}
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "SCHEDULED,DISPATCHED",
sortBy: "scheduledDate",
sortOrder: "ASC",
tab: activeTab,
};
}
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
...(statusFilter ? { statuses: statusFilter } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
};
}, [
isOperationsTab,
operationsSubTab,
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
kindTab,
statusFilter,
directionFilter,
freightTypeFilter,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
@@ -171,18 +166,6 @@ export default function BookingRequestsPage() {
void refetchSummary();
}, [refetch, refetchSummary]);
const handleAllocateFromQueue = useCallback(
(ids: string[]) => {
const selected = rows.filter((b) => ids.includes(b.id));
const sorted = [...selected].sort(
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
);
setAllocateIds(sorted.map((b) => b.id));
setAllocateOpen(true);
},
[rows],
);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
@@ -356,6 +339,8 @@ export default function BookingRequestsPage() {
]}
/>
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
old BookingStatusTabs is commented out — status is now a filter select.
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
@@ -364,73 +349,97 @@ export default function BookingRequestsPage() {
}}
counts={tabCounts}
/>
*/}
<Tabs
value={kindTab}
onChange={(value) => {
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{BOOKING_KIND_TABS.map((t) => (
<Tabs.Tab key={t.value} value={t.value}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
searchable
radius="lg"
style={{ minWidth: 200 }}
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
/>
</Group>
</Stack>
</Box>
{isOperationsTab ? (
<Box px="md" pb="md">
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
</Box>
) : showEmpty ? (
{showEmpty ? (
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}

View File

@@ -61,9 +61,11 @@ import {
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { fileViewUrl } from "@/constants/apiConfig";
import { downloadBookingFile } from "@/services/files.service";
import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize).
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
enabled: Boolean(id) && showClearanceTabQuery,
});
// Customer profile documents (national ID, TIN, import/business license) for
// the company this contract belongs to. Shown as a separate section in the
// Documents tab, alongside the contract's own attached files.
const companyId = contract?.companyId ?? "";
const profileDocumentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: companyId },
enabled: Boolean(companyId),
}),
);
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
? profileDocumentsQuery.data
: [];
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
const profileDocuments = profileDocumentsRaw.map(
(doc: CustomerDocument) =>
({
id: doc.id,
code: doc.code,
name: doc.name,
url: doc.url ?? "",
mimeType: doc.mimeType,
size: doc.size,
resourceId: companyId,
resource: "company",
}) satisfies NonNullable<Freight.IContract["files"]>[number],
);
const downloadContractPdf = async () => {
if (!contract?.id) return;
try {
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
const selfClear = !contract.customsClearingEnabled;
const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract");
// Signature files (code `signature_<role>`) are baked into the contract PDF —
// don't list them as standalone documents in the Documents tab.
const contractDocuments = files.filter(
(f) => !f.code.startsWith("signature_"),
);
const hasContractDocument = Boolean(
contractPdf || contract.contractGeneratedAt,
);
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
value="documents"
leftSection={<Files size={16} />}
rightSection={
files.length > 0 ? (
contractDocuments.length + profileDocuments.length > 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{files.length}
{contractDocuments.length + profileDocuments.length}
</Badge>
) : null
}
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
) : currentTab === "documents" ? (
<Stack gap="lg">
<ContractDocumentsCard
files={files}
files={contractDocuments}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
emptyText={
profileDocumentsQuery.isLoading
? "Loading customer documents…"
: "No profile documents on file for this customer."
}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>

View File

@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(

View File

@@ -8,6 +8,7 @@ import {
Paper,
RingProgress,
Stack,
Tabs,
Text,
Textarea,
TextInput,
@@ -25,10 +26,12 @@ import {
LayoutGrid,
Navigation,
Package,
PackageCheck,
Route as RouteIcon,
Send,
Train,
Weight,
Workflow as WorkflowIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -46,6 +49,7 @@ import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/Imp
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
@@ -1053,6 +1057,18 @@ export default function TrainScheduleV2DetailPage() {
</Paper>
) : null}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
Workflow
</Tabs.Tab>
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
Workspace
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workflow">
<Stack gap="lg">
<Paper radius="xl" p="lg">
<Stack gap="lg">
{/* Workflow header with ring progress */}
@@ -1110,7 +1126,20 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
<ScheduleBatchPanel schedule={schedule} />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="workspace">
<ScheduleWorkspacePanel
schedule={schedule}
onChanged={() => {
autoPreviewedRef.current = false;
void detailQuery.refetch();
}}
/>
</Tabs.Panel>
</Tabs>
{scheduleId ? (
<RescheduleTrainDialog

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
@@ -19,32 +20,66 @@ export default function TrainSchedulingGlobalRulesPage() {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
// `numeric` columns come back from the API as strings (e.g. "250.00").
// Coerce every field to a real number so Mantine's controlled
// NumberInput edits cleanly (a string value fights the caret) and the
// default can be cleared and replaced.
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
setForm(numeric);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
}, [toast]);
// Run once on mount only. `toast` from useToast is a fresh function every
// render — listing it here re-fired the effect on every render, refetching
// the rules and overwriting whatever the user was typing (values snapped
// back to the saved defaults).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
// Every field must hold a real number — an empty box (cleared but not
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
const raw = form[key];
const num = raw === "" || raw == null ? NaN : Number(raw);
if (!Number.isFinite(num)) {
toast({
title: "All fields are required — fill every value before saving.",
variant: "destructive",
});
return;
}
payload[key] = num;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
importWindowLeadDays: Number(form.importWindowLeadDays),
exportBookingLeadHours: Number(form.exportBookingLeadHours),
windowOpenHour: Number(form.windowOpenHour),
windowDurationHours: Number(form.windowDurationHours),
docReviewMinutes: Number(form.docReviewMinutes),
paymentWindowMinutes: Number(form.paymentWindowMinutes),
reopenDelayMinutes: Number(form.reopenDelayMinutes),
});
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
@@ -70,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -80,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -89,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -102,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
max20ftContainerWeightTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0.001}
disabled={loading}
/>
@@ -115,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
max20ftPairWeightDiffTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0}
disabled={loading}
/>
@@ -127,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
title="Booking windows"
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
/>
<NumberInput
label="Import window lead (days)"
description="The single booking day opens this many days before departure"
<DurationField
label="Import window lead"
description="The single booking day opens this long before departure"
value={form.importWindowLeadDays ?? ""}
nativeUnit="days"
onChange={(value) =>
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Export booking lead (hours)"
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
<DurationField
label="Export booking lead"
description="Export bookings are accepted first-come-first-serve starting this long before departure"
value={form.exportBookingLeadHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
@@ -154,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<NumberInput
label="Window duration (hours)"
<DurationField
label="Window duration"
description="How long the import booking window stays open"
value={form.windowDurationHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, windowDurationHours: value }))
}
min={0.25}
max={12}
step={0.25}
min={1}
disabled={loading}
/>
<NumberInput
label="Document review (minutes)"
<DurationField
label="Document review"
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Payment window (minutes)"
<DurationField
label="Payment window"
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Reopen delay (minutes)"
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}

View File

@@ -44,6 +44,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
CompositionRemovalEntry,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -283,6 +284,18 @@ export const api = {
],
),
contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>(
"train-scheduling",
"contract-booking-windows",
({ contractId }) =>
trainSchedulingService.getContractBookingWindows(contractId),
({ contractId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"contract-booking-windows",
contractId,
],
),
availableDays: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
string[]

View File

@@ -17,6 +17,8 @@ export interface BookingListFilter {
// customerId?: string;
companyId?: string;
freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
page?: number;
@@ -125,6 +127,7 @@ export const bookingsService = {
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
@@ -148,6 +151,7 @@ export const bookingsService = {
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}

View File

@@ -6,6 +6,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
CompositionRemovalEntry,
UnassignedBookingsResponse,
@@ -108,6 +109,19 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Booking windows for every route/schedule of a contract. A window with
* `isOpenNow === true` means GL may create a booking right now for that route.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<BookingWindow[]> => {
const response = await client.get<BookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,

View File

@@ -69,6 +69,17 @@ export interface BookingCompany {
website?: string | null;
}
/** One physical container under a line — its own number + verified gross mass. */
export interface BookingContainerUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
sortOrder?: number;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
@@ -80,6 +91,8 @@ export interface BookingContainerLine {
label?: string;
sizeFt?: number;
};
/** Per-physical-container rows (number + weight). Empty when not captured. */
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {

View File

@@ -325,6 +325,25 @@ export interface BatchBoardScheduleDetail {
allocationViolations: string[];
}
/**
* A booking window for one of a contract's routes/schedules. `isOpenNow === true`
* means a booking may be created right now for that route. Times are ISO strings;
* render them in EAT (Africa/Addis_Ababa).
*/
export interface BookingWindow {
scheduleId: string;
direction: string | null;
windowPhase: BookingWindowPhase | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: Array<{ id: string; reference: string; reason: string }>;
@@ -366,6 +385,11 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
route?: {
id: string;
name: string;

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
import { AlertCircle, Upload } from "lucide-react";
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
import { api } from "@/services/api";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
@@ -13,6 +13,10 @@ interface ContractClearanceActionProps {
label?: string;
size?: ButtonProps["size"];
urgent?: boolean;
/** GL's turn — render as a calm status button, not a call to action. */
waiting?: boolean;
/** Icon override from the phase-aware action derivation. */
icon?: LucideIcon;
}
export function ContractClearanceAction({
@@ -20,6 +24,8 @@ export function ContractClearanceAction({
label: labelProp,
size = "xs",
urgent = false,
waiting = false,
icon: iconProp,
}: ContractClearanceActionProps) {
const [opened, { open, close }] = useDisclosure(false);
@@ -38,7 +44,13 @@ export function ContractClearanceAction({
return urgent ? "Upload clearance" : "Manage clearance";
}, [labelProp, clearance, urgent]);
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
const Icon =
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
// Urgent (customer's turn) = filled orange so it stands out among the green
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
const variant = waiting ? "light" : "filled";
return (
<ModalSafeWrapper>
@@ -47,7 +59,8 @@ export function ContractClearanceAction({
radius="md"
fw={700}
fz={13}
color="edr-green"
color={color}
variant={variant}
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();

View File

@@ -46,6 +46,8 @@ export function ContractCustomerAction({
label={action.label}
size={size}
urgent={action.urgent}
waiting={action.waiting}
icon={action.icon}
/>
);
}

View File

@@ -4,8 +4,10 @@ import {
CreditCard,
Eye,
FileSignature,
Hourglass,
PackagePlus,
PencilLine,
Receipt,
RotateCcw,
Upload,
} from "lucide-react";
@@ -61,6 +63,8 @@ export type ContractCustomerAction =
primary: boolean;
icon: LucideIcon;
urgent: boolean;
/** True when it's GL's turn — render calm/informational, not a call to action. */
waiting?: boolean;
}
| {
type: "pay";
@@ -126,14 +130,56 @@ export function deriveContractCustomerAction(
const clr = contractNeedsClearanceAction(contract);
if (clr.show) {
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
// Refine the generic clearance action by the persisted clearance phase so
// the button says what the customer actually has to do right now (e.g.
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
const phase = contract.clearancePhase ?? null;
switch (phase) {
case "CUSTOMER_INTAKE":
return {
type: "clearance",
contractId: id,
label: "Upload clearance documents",
primary: true,
icon: Upload,
urgent: true,
};
case "CUSTOMER_DUTY":
return {
type: "clearance",
contractId: id,
label: "Pay duty & upload slip",
primary: true,
icon: Receipt,
urgent: true,
};
case "GL_ET_REVIEW":
case "GL_DJ_COLLECTION":
case "GL_ET_OUTPUT":
case "GL_ET_POST_CLEARANCE":
case "GL_DJ_LOADING":
case "POST_TRANSIT":
// GL's turn — nothing for the customer to do; show a calm status.
return {
type: "clearance",
contractId: id,
label: "Clearance in progress",
primary: false,
icon: Hourglass,
urgent: false,
waiting: true,
};
default:
// No persisted phase (legacy / early cycles) — keep the status-derived label.
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
}
}
if (

View File

@@ -148,6 +148,8 @@ export const URL_CONSTANTS = {
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows",
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/api/train-scheduling/contracts/${contractId}/booking-windows`,
},
PAYMENTS: {

View File

@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
export interface ActionItem {
id: string;
/** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "sign" | "book" | "pay";
kind: "clearance" | "duty" | "sign" | "book" | "pay";
/** The contract/booking reference for display. */
reference: string;
/** Short human description of the action. */

View File

@@ -8,6 +8,7 @@ import {
FilePlus2,
FileSignature,
PackagePlus,
Receipt,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -34,6 +35,7 @@ const KIND_META: Record<
{ icon: typeof Upload; label: string; color: string }
> = {
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" },
@@ -96,6 +98,19 @@ export function ActionNeededSection({
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
// Duty phase: the customer's task is paying duty/tax and uploading the
// slip — a distinct, money action, not a generic document upload.
if (view?.phase === "CUSTOMER_DUTY") {
out.push({
id: `duty-${c.id}`,
kind: "duty",
reference: c.reference,
description: "Duty / tax payment due — pay and upload the slip",
targetId: c.id,
urgent: true,
});
return;
}
// Only surface when there's something the customer can do: a query, or the
// contract is awaiting their (re)upload.
if (queried === 0 && !awaiting) return;
@@ -162,6 +177,10 @@ export function ActionNeededSection({
case "clearance":
setClearanceId(item.targetId);
break;
case "duty":
// Duty advice + payment-slip upload live on the contract detail page.
navigate(`/contracts/${item.targetId}`);
break;
case "pay":
setPayItem(item);
break;
@@ -249,6 +268,8 @@ export function ActionNeededSection({
leftSection={
item.kind === "clearance" ? (
<Upload size={14} />
) : item.kind === "duty" ? (
<Receipt size={14} />
) : (
<FilePlus2 size={14} />
)
@@ -256,13 +277,15 @@ export function ActionNeededSection({
>
{item.kind === "pay"
? "Pay now"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: item.urgent
? "Upload documents"
: "Upload"}
: item.kind === "duty"
? "Pay duty & upload slip"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: item.urgent
? "Upload documents"
: "Upload"}
</Button>
</Group>
);

View File

@@ -1,7 +1,8 @@
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, CalendarClock } from "lucide-react";
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { Card } from "./Card";
@@ -43,6 +44,29 @@ function windowLabel(w: MyBookingWindow): string {
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
}
/**
* The deadline + label for whichever phase the window is currently in. Phases
* run: window open (closes at windowClosesAt) → document review (docReviewEndsAt)
* → payment (paymentPhaseEndsAt). Returns null when no phase is timing down.
*/
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string } | null {
switch (w.windowPhase) {
case "OPEN":
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt };
return null;
case "DOC_REVIEW":
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt };
return null;
case "PAYMENT":
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt };
return null;
default:
return null;
}
}
function Pill({
children,
bg,
@@ -162,11 +186,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
borderRadius: 12,
border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`,
backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined,
cursor: w.isOpenNow ? "pointer" : "default",
}}
onClick={
w.isOpenNow ? () => navigate("/contracts") : undefined
}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
@@ -184,11 +204,45 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
</Text>
</Group>
{(() => {
const cd = phaseCountdown(w);
return cd ? (
<Box mt={4}>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
size="xs"
/>
</Box>
) : null;
})()}
</Box>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<DirectionBadge direction={w.direction} />
<StatusBadge window={w} />
{/* ONE_TIME contracts book via their own single-shipment flow,
not window drawdown — show the window + countdown but no
"Book now" entry. */}
{w.isOpenNow && w.contractKind !== "ONE_TIME" && (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<PackagePlus size={14} />}
// Book straight against the row's contract when it carries
// one; otherwise fall back to the contract list to pick.
onClick={() =>
navigate(
w.contractId
? `/contracts/${w.contractId}/bookings/new`
: "/contracts",
)
}
>
Book now
</Button>
)}
</Group>
</Group>
))}

View File

@@ -1,12 +1,10 @@
import { Box, Group, Text } from "@mantine/core";
import { Group } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, Download, Eye } from "lucide-react";
import { CreditCard } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
@@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
import { DocRow, IconSquare } from "./components/Documents";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
ConsolidationPairedNotice,
@@ -51,7 +48,7 @@ export function ReadonlyBookingView({
useScrollToHash();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { view, viewer } = useFileViewer();
const { viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the
// New Contract page. Fall back to /contracts/new only if the link is missing.
@@ -160,7 +157,6 @@ export function ReadonlyBookingView({
)
}
menuActions={{
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
onRebook,
onSupport: () => navigate("/support"),
}}
@@ -199,7 +195,7 @@ export function ReadonlyBookingView({
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} navigate={navigate} />
<ContractCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />}
@@ -220,52 +216,6 @@ export function ReadonlyBookingView({
)}
<WarehousePaymentsSection bookingId={booking.id} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Documents</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{booking.files.length} files
</Text>
</Group>
<Box>
{booking.files.map((file, i) => (
<DocRow
key={file.id}
last={i === booking.files!.length - 1}
title={file.name}
meta={file.code.replace(/_/g, " ")}
status="verified"
action={
<Group gap={6} wrap="nowrap">
{isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
<IconSquare
icon={<Eye size={16} />}
onClick={() =>
view({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
}
/>
)}
<IconSquare
href={fileViewUrl(file.id, true)}
icon={<Download size={16} />}
/>
</Group>
}
/>
))}
</Box>
</SectionCard>
)}
<ActivityCard booking={booking} />
</>
}

View File

@@ -1,6 +1,4 @@
import { Box, Button, Group, Paper, Text } from "@mantine/core";
import { FileSignature } from "lucide-react";
import type { useNavigate } from "react-router-dom";
import { Box, Group, Paper, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record<
},
};
export function ContractCard({
booking,
navigate,
}: {
booking: Freight.IBooking;
navigate: ReturnType<typeof useNavigate>;
}) {
export function ContractCard({ booking }: { booking: Freight.IBooking }) {
const c = CONTRACT_CONFIG[booking.status as string];
if (!c) return null;
@@ -76,20 +68,6 @@ export function ContractCard({
{c.description}
</Text>
</Box>
{c.buttonLabel && (
<Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
radius={10}
color="edr-green"
leftSection={<FileSignature size={18} />}
styles={{
root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
{c.buttonLabel}
</Button>
)}
</Group>
</Paper>
);

View File

@@ -15,6 +15,18 @@ const PHASE_LABELS: Record<string, string> = {
POST_TRANSIT: "Transit",
};
/** One-line hint under each phase label, for the vertical layout. */
const PHASE_HINTS: Record<string, string> = {
CUSTOMER_INTAKE: "You upload the required clearance documents",
GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia",
GL_DJ_COLLECTION: "Delivery order collected in Djibouti",
GL_ET_OUTPUT: "Customs declaration prepared",
CUSTOMER_DUTY: "You pay the assessed duty / tax",
GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised",
GL_DJ_LOADING: "Cargo loaded for departure",
POST_TRANSIT: "In transit",
};
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -51,62 +63,92 @@ export function ClearancePhaseStepper({
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
const dot = compact ? 26 : 30;
const rowGap = compact ? 18 : 24;
// Vertical timeline: every phase is a row, so all steps stay visible on any
// width without horizontal scrolling. The connector runs down between dots.
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
<Stack gap={0}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
// const doneOrActive = isComplete || isActive;
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
<Group key={phase} gap={12} wrap="nowrap" align="flex-start">
{/* Dot + connector column */}
<Stack gap={0} align="center" style={{ flexShrink: 0, alignSelf: "stretch" }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: dot,
height: dot,
borderRadius: "50%",
flexShrink: 0,
background: isComplete
? BRAND_GREEN
: isActive
? "white"
: "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete
? "white"
: isActive
? BRAND_GREEN
: "var(--mantine-color-gray-5)",
}}
>
{isComplete ? (
<Check size={compact ? 14 : 16} strokeWidth={3} />
) : (
<Text size="xs" fw={700}>
{index + 1}
</Text>
)}
</Box>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
minHeight: rowGap,
marginBlock: 4,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
background: isComplete
? BRAND_GREEN
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
</Stack>
{/* Label + hint */}
<Box pb={isLast ? 0 : rowGap} style={{ minWidth: 0, paddingTop: 3 }}>
<Text
size="sm"
fw={isActive ? 700 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
lh={1.2}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
{PHASE_HINTS[phase] && (
<Text size="xs" c="dimmed" mt={2} lh={1.3}>
{PHASE_HINTS[phase]}
</Text>
)}
</Box>
</Group>
);
})}
</Group>
</Stack>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({
onDownload={downloadWorkflowFile}
/>
{clearance.bookingReady ? (
{clearance.linkedBookingId ? (
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
<Stack gap={6}>
<Text fz={13} fw={600} style={{ color: INK }}>
Shipment booking created
{clearance.linkedBookingReference
? ` · ${clearance.linkedBookingReference}`
: ""}
</Text>
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>
<Button
size="compact-sm"
variant="light"
color="green"
leftSection={<ArrowRight size={14} />}
component="a"
href={`/bookings/${clearance.linkedBookingId}`}
style={{ alignSelf: "flex-start" }}
>
View shipment booking
</Button>
</Stack>
</Alert>
) : clearance.bookingReady ? (
<Alert color="green" variant="light">
Clearance is complete. Global Logistics will create your shipment booking shortly.
</Alert>

View File

@@ -61,6 +61,7 @@ import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBann
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
import {
BORDER,
ContractStatusBadge,
@@ -198,6 +199,18 @@ export default function ContractDetailPage() {
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
);
// Booking windows for this contract's routes — gates the direct "New shipment
// booking" entry so the customer only sees it while a window is open.
// Refetched every minute so "Open now" flips without a manual reload.
const { data: bookingWindows = [] } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
const bookingWindowOpen = hasOpenWindow(bookingWindows);
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
@@ -370,17 +383,40 @@ export default function ContractDetailPage() {
Request shipment
</Button>
)}
{canBookShipment && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() => navigate(`/contracts/${contract.id}/bookings/new`)}
>
New shipment booking
</Button>
)}
{canBookShipment &&
(bookingWindowOpen ? (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(`/contracts/${contract.id}/bookings/new`)
}
>
New shipment booking
</Button>
) : (
<Paper
withBorder
radius="md"
px="md"
py={10}
maw={420}
style={{ borderColor: BORDER, background: "#F8FAFC" }}
>
<Group gap={10} align="flex-start" wrap="nowrap">
<CalendarClock
size={16}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
</Paper>
))}
{glPreparingBooking && (
<Badge
size="lg"
@@ -1102,7 +1138,7 @@ export default function ContractDetailPage() {
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel>
{canBookShipment && (
{canBookShipment && bookingWindowOpen && (
<Button
color="edr-green"
radius="md"
@@ -1116,6 +1152,18 @@ export default function ContractDetailPage() {
</Button>
)}
</Group>
{canBookShipment && !bookingWindowOpen && (
<Group gap={8} align="flex-start" wrap="nowrap" mb="md">
<CalendarClock
size={15}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
)}
{contractBookings.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />

View File

@@ -0,0 +1,271 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
Check,
CircleDot,
FileEdit,
FilePlus2,
Gavel,
PenLine,
Send,
ShieldCheck,
Truck,
XCircle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Freight } from "@edr/types";
import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui";
/**
* The customer-facing contract journey, in order. This is the *contract track*
* (establishing the agreement) — the per-shipment booking/clearance journey is a
* separate stepper (`ClearancePhaseStepper`) shown on a booking, not here.
*/
interface Stage {
key: string;
label: string;
icon: LucideIcon;
}
const STAGES: Stage[] = [
{ key: "draft", label: "Draft", icon: FileEdit },
{ key: "submitted", label: "Submitted", icon: Send },
{ key: "accepted", label: "Accepted", icon: ShieldCheck },
{ key: "approval", label: "Approval", icon: Gavel },
{ key: "sign", label: "Signature", icon: PenLine },
{ key: "active", label: "Active", icon: FilePlus2 },
{ key: "shipping", label: "Shipping", icon: Truck },
];
const STAGE_INDEX: Record<string, number> = STAGES.reduce(
(acc, s, i) => ({ ...acc, [s.key]: i }),
{},
);
type Terminal = "REJECTED" | "CANCELLED" | "EXPIRED" | "CLOSED" | null;
interface StepState {
/** Index into STAGES of the stage the contract is currently working on. */
activeIdx: number;
/** Terminal state, if the contract ended off the happy path. */
terminal: Terminal;
/** One-line "what happens next" helper for the customer. */
next: string;
}
/**
* Map any contract status onto the journey. Statuses that share a stage (e.g.
* every approval/signature sub-state) collapse onto that stage; the helper line
* is what disambiguates them for the customer.
*/
function resolveStep(status: string): StepState {
const at = (key: string): number => STAGE_INDEX[key] ?? 0;
switch (status) {
case "DRAFT":
case "RENEWAL_DRAFT":
return { activeIdx: at("draft"), terminal: null, next: "Finish and submit this contract for review." };
case "SUBMITTED":
case "RENEWAL_SUBMITTED":
return { activeIdx: at("submitted"), terminal: null, next: "Waiting for staff to accept your submission." };
case "PRICE_CHANGED_PENDING_CONFIRM":
return { activeIdx: at("submitted"), terminal: null, next: "Price changed since preview — confirm to resubmit." };
case "CHANGES_REQUESTED":
return { activeIdx: at("submitted"), terminal: null, next: "Staff requested changes — update and resubmit." };
case "AMENDMENTS_PROPOSED":
return { activeIdx: at("submitted"), terminal: null, next: "Amendments proposed — review the proposed changes." };
case "PENDING_APPROVAL":
case "RENEWAL_PENDING_APPROVAL":
return { activeIdx: at("approval"), terminal: null, next: "Under internal approval (staff → director → CEO)." };
case "APPROVED":
return { activeIdx: at("approval"), terminal: null, next: "Approved — the contract document is being prepared." };
case "APPROVED_PENDING_SIGNATURE":
case "CONTRACT_READY":
return { activeIdx: at("sign"), terminal: null, next: "Contract is ready — review and sign it." };
case "SIGNED_CUSTOMER":
return { activeIdx: at("sign"), terminal: null, next: "You've signed — waiting for staff to counter-sign." };
case "CONTRACT_ACTIVE":
case "FULLY_EXECUTED":
case "CLEARANCE_READY_FOR_BOOKING":
return { activeIdx: at("active"), terminal: null, next: "Active — submit a shipment request to start shipping." };
case "AWAITING_CLEARANCE_DOCUMENTS":
return { activeIdx: at("active"), terminal: null, next: "Upload the pre-booking clearance documents." };
case "CLEARANCE_UNDER_REVIEW":
return { activeIdx: at("active"), terminal: null, next: "Global Logistics is reviewing your clearance documents." };
case "ACTIVE_SHIPMENT_IN_PROGRESS":
return { activeIdx: at("shipping"), terminal: null, next: "A shipment is in progress under this contract." };
// ── Terminal ──
case "REJECTED":
return { activeIdx: at("approval"), terminal: "REJECTED", next: "This contract was rejected." };
case "CANCELLED":
return { activeIdx: at("draft"), terminal: "CANCELLED", next: "This contract was cancelled." };
case "EXPIRED":
return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." };
case "CONTRACT_CLOSED":
case "ARCHIVED":
return { activeIdx: STAGES.length - 1, terminal: "CLOSED", next: "This contract is closed." };
default:
return { activeIdx: at("draft"), terminal: null, next: "" };
}
}
/** Days until the contract validity lapses, if any (negative = already lapsed). */
function daysUntil(dateIso?: string | null): number | null {
if (!dateIso) return null;
const end = new Date(dateIso).getTime();
if (Number.isNaN(end)) return null;
const ms = end - Date.now();
return Math.ceil(ms / 86_400_000);
}
export interface ContractStepBannerProps {
contract: Freight.IContract;
}
/**
* A polished, branded step banner that shows where a contract sits in its
* lifecycle. Rendered inside the expanded region of a contract row.
*/
export function ContractStepBanner({ contract }: ContractStepBannerProps) {
const { activeIdx, terminal, next } = resolveStep(contract.status);
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
const expiryDays = daysUntil(contract.contractValidUntil);
const expirySoon =
!terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14;
return (
<Box
style={{
borderRadius: 14,
padding: "18px 20px",
border: `1px solid ${isTerminalBad ? "#F3C6C1" : BORDER}`,
background: isTerminalBad
? "linear-gradient(135deg, #FEF3F2 0%, #FFFFFF 60%)"
: `linear-gradient(135deg, ${GREEN}12 0%, ${GREEN}06 26%, #FFFFFF 68%)`,
}}
>
{/* Stepper row */}
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{STAGES.map((stage, index) => {
const isComplete = !isTerminalBad && index < activeIdx;
const isActive = !isTerminalBad && index === activeIdx;
const isFailedHere = isTerminalBad && index === activeIdx;
const isLast = index === STAGES.length - 1;
const Icon = isFailedHere ? XCircle : stage.icon;
return (
<Box key={stage.key} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 78 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={5} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: "50%",
background: isComplete
? GREEN_DARK
: isFailedHere
? "#D92D20"
: isActive
? "#FFFFFF"
: "#F1F5F8",
border: isActive
? `2px solid ${GREEN}`
: isComplete || isFailedHere
? "2px solid transparent"
: `2px solid ${BORDER}`,
color: isComplete || isFailedHere
? "#FFFFFF"
: isActive
? GREEN_DARK
: "#9AA9B7",
boxShadow: isActive ? `0 0 0 4px ${GREEN}22` : "none",
transition: "all 160ms ease",
}}
>
{isComplete ? (
<Check size={17} strokeWidth={3} />
) : (
<Icon size={17} strokeWidth={isActive ? 2.4 : 2} />
)}
</Box>
<Text
fz={11}
fw={isActive ? 700 : 600}
ta="center"
style={{
whiteSpace: "nowrap",
color: isActive ? GREEN_DARK : isComplete ? INK : MUTED,
}}
>
{stage.label}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2.5,
marginInline: 6,
marginBottom: 20,
borderRadius: 2,
background: isComplete ? GREEN_DARK : BORDER,
transition: "background 160ms ease",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
{/* Helper line + expiry hint */}
{(next || expirySoon) && (
<Group gap={16} wrap="wrap" mt={14} align="center">
{next && (
<Group gap={7} wrap="nowrap" align="center">
{isTerminalBad ? (
<XCircle size={15} color="#D92D20" />
) : (
<CircleDot size={15} color={GREEN_DARK} />
)}
<Text fz={12.5} fw={600} style={{ color: isTerminalBad ? "#B42318" : INK }}>
{next}
</Text>
</Group>
)}
{expirySoon && (
<Group gap={6} wrap="nowrap" align="center">
<AlertTriangle size={14} color="#9A6700" />
<Text fz={12} fw={600} c="#9A6700">
{expiryDays === 0
? "Validity ends today"
: `Validity ends in ${expiryDays} day${expiryDays === 1 ? "" : "s"}`}
</Text>
</Group>
)}
</Group>
)}
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -17,6 +17,7 @@ import {
} from "@mantine/core";
import {
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
FileStack,
@@ -43,6 +44,7 @@ import {
MUTED,
StatCard,
} from "./contract-ui";
import { ContractStepBanner } from "./ContractStepBanner";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -61,6 +63,15 @@ export default function ContractsList() {
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const nextSet = new Set(prev);
if (nextSet.has(id)) nextSet.delete(id);
else nextSet.add(id);
return nextSet;
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -326,6 +337,7 @@ export default function ContractsList() {
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 44 }} aria-label="Expand" />
<Table.Th>Contract</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th>
@@ -340,7 +352,7 @@ export default function ContractsList() {
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
@@ -350,7 +362,7 @@ export default function ContractsList() {
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
@@ -362,7 +374,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
@@ -383,12 +395,48 @@ export default function ContractsList() {
const tradeLabel = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
const isOpen = expanded.has(c.id);
return (
<Fragment key={c.id}>
<Table.Tr
key={c.id}
style={{ cursor: "pointer" }}
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={isOpen ? "Hide progress" : "Show progress"}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
@@ -483,6 +531,14 @@ export default function ContractsList() {
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={10} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>

View File

@@ -52,6 +52,7 @@ import {
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
@@ -65,7 +66,19 @@ export default function NewShipmentPage() {
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
if (isLoading) {
// Coarse booking-window gate: block the form entirely when no window is
// currently open for the contract's routes. The day-picker inside the form
// still narrows to bookable days; this is the outer "is booking open at all"
// check that mirrors the contract detail page.
const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
if (isLoading || windowsLoading) {
return (
<Center mih={400} p="xl">
<Loader color="edr-green" />
@@ -113,6 +126,60 @@ export default function NewShipmentPage() {
);
}
// Coarse gate: if the customer deep-links here while no booking window is
// open, show the same closed-state notice as the contract page instead of the
// form. Still allowed the moment any window isOpenNow.
if (!hasOpenWindow(bookingWindows)) {
return (
<Box style={{ padding: "28px 0 0" }}>
<Group
justify="space-between"
px="24px"
align="flex-end"
wrap="wrap"
gap="md"
mb="lg"
>
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
New Shipment Booking
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Book a shipment against contract {contract.reference}.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/contracts/${contract.id}`)}
>
Back to contract
</Button>
</Group>
<Box px="24px">
<Alert
color="yellow"
variant="light"
radius="md"
icon={<CalendarDays size={18} />}
title="Booking is not open right now"
>
<Text size="sm">{closedWindowMessage(bookingWindows)}</Text>
<Text size="sm" mt="xs">
Come back when the booking window opens to book your shipment.
</Text>
</Alert>
</Box>
</Box>
);
}
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
}

View File

@@ -0,0 +1,63 @@
import type { MyBookingWindow } from "@/services/bookings.service";
/** All booking-window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** "Thu, 10 Jul, 08:00 EAT" — a full opening date/time in Addis Ababa time. */
export function formatWindowOpensAt(iso: string): string {
const day = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: TZ,
});
return `${day}, ${time}`;
}
/** True when at least one of the contract's windows is bookable right now. */
export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
/**
* The soonest upcoming (not-yet-open) window with a known opening time, so the
* customer can be told when to come back. Returns `null` when nothing upcoming
* carries an opening time.
*/
export function soonestUpcomingWindow(
windows: MyBookingWindow[],
): MyBookingWindow | null {
const upcoming = windows
.filter((w) => !w.isOpenNow && w.windowOpensAt)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
);
return upcoming[0] ?? null;
}
/**
* The closed-state message shown when no booking window is open: the soonest
* upcoming window's opening time + lane, or a generic notice when nothing is
* scheduled.
*/
export function closedWindowMessage(windows: MyBookingWindow[]): string {
const next = soonestUpcomingWindow(windows);
if (!next || !next.windowOpensAt) {
return "No upcoming booking window scheduled.";
}
const lane =
next.origin && next.destination
? ` for ${next.origin}${next.destination}`
: "";
return `Booking is not open right now. Next window: ${formatWindowOpensAt(
next.windowOpensAt,
)} EAT${lane}.`;
}

View File

@@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
const CONTRACT_TYPE_OPTIONS = [
{ value: "new", label: "New Contract" },
{ value: "renewal", label: "Contract Renewal" },
// Renewal is disabled for now — not yet available to customers.
{ value: "renewal", label: "Contract Renewal (coming soon)", disabled: true },
];
type ContractForm = UseFormReturn<

View File

@@ -376,6 +376,12 @@ export const api = {
"myBookingWindows",
() => bookingsService.getMyBookingWindows(),
),
getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>(
"train-scheduling",
"contractBookingWindows",
({ contractId }) => bookingsService.getContractBookingWindows(contractId),
),
},
contracts: {

View File

@@ -53,11 +53,17 @@ export interface PriceLineItem {
*/
export interface MyBookingWindow {
scheduleId: string;
/** Contract whose route this window belongs to, when the row carries it. */
contractId: string | null;
/** ONE_TIME contracts can't draw down against a window — button is hidden. */
contractKind: "ONE_TIME" | "GENERAL" | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -375,4 +381,18 @@ export const bookingsService = {
);
return data.data ?? data;
},
/**
* Booking windows for a single contract's routes (same row shape as
* `getMyBookingWindows`). Used to gate the direct "New shipment booking"
* entry on the contract detail page and the new-shipment form.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<MyBookingWindow[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return data.data ?? data;
},
};