Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,

View File

@@ -1,5 +1,6 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -8,6 +9,7 @@ import type {
MileLegSummary,
MileVehicleSummary,
} from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import { CardTitle, SectionCard } from "./layout";
@@ -171,12 +173,85 @@ function LegBlock({
);
}
/**
* Reference row for the stored last-mile contract: signed status, open the
* contract page (view / sign), download the PDF.
*/
function LastMileContractRow({
bookingId,
requestId,
signedAt,
signerDisplayName,
}: {
bookingId: string;
requestId: string;
signedAt?: string | null;
signerDisplayName?: string | null;
}) {
const navigate = useNavigate();
const download = async () => {
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "last-mile-contract.pdf";
a.click();
URL.revokeObjectURL(url);
};
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
Last-mile contract
</Text>
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
{signedAt
? `Signed ${new Date(signedAt).toLocaleDateString()}${
signerDisplayName ? ` by ${signerDisplayName}` : ""
}`
: "Awaiting your signature"}
</Text>
</Stack>
<Group gap={8}>
<Button
size="xs"
variant="light"
onClick={() =>
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
}
>
{signedAt ? "View contract" : "View & sign"}
</Button>
<Button size="xs" variant="default" onClick={() => void download()}>
Download PDF
</Button>
</Group>
</Group>
);
}
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
const { data } = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
});
// The stored LM contract lives on the booking's approved last-mile request.
const { data: lmRequests } = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: !!booking.lastMileDeliveryAddress,
});
const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED");
const firstLeg = data?.firstMile ?? null;
const lastLeg = data?.lastMile ?? null;
@@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
/>
)}
{showLast && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
<Box>
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
{approvedRequest && (
<LastMileContractRow
bookingId={booking.id}
requestId={approvedRequest.id}
signedAt={approvedRequest.customerSignedAt}
signerDisplayName={approvedRequest.signerDisplayName}
/>
)}
</Box>
)}
</Stack>
</SectionCard>

View File

@@ -21,6 +21,7 @@ import {
Clock,
FileText,
Package,
PackageCheck,
Upload,
} from "lucide-react";
import { useState } from "react";
@@ -44,6 +45,7 @@ import {
DOC_STATE_COLOR,
DOC_STATE_LABEL,
} from "./booking-doc-state";
import ShippingLineCompleteModal from "./ShippingLineCompleteModal";
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
/**
@@ -69,6 +71,7 @@ export default function ShippingLineBookingDetailPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [docsOpen, setDocsOpen] = useState(false);
const [completeOpen, setCompleteOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -78,6 +81,14 @@ export default function ShippingLineBookingDetailPage() {
enabled: Boolean(id),
});
// Trains dedicated to this shipping line — matched to the booking below by
// lane (+ shipment day when one is set) so the detail shows which departure
// will carry it.
const trainsQuery = useQuery({
queryKey: ["shipping-line-my-trains"],
queryFn: shippingLineBookingsService.myTrains,
});
const cancelMutation = useMutation({
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
onSuccess: () => {
@@ -108,6 +119,19 @@ export default function ShippingLineBookingDetailPage() {
}
const booking: ShippingLineBooking = bookingQuery.data;
// A train matches when it runs the booking's lane; with a shipment day set,
// it must also depart that calendar day.
const sameDay = (a: string | Date, b: string | Date) =>
new Date(a).toDateString() === new Date(b).toDateString();
const matchedTrains = (trainsQuery.data ?? []).filter(
(train) =>
train.originYardId === booking.originYard?.id &&
train.destinationYardId === booking.destinationYard?.id &&
(!booking.scheduledDate ||
sameDay(train.scheduledDepartureDate, booking.scheduledDate)),
);
const status = booking.status as string;
const docState = bookingDocState(booking);
const showDocs = hasDocuments(docState);
@@ -120,6 +144,12 @@ export default function ShippingLineBookingDetailPage() {
const canCancel =
CANCELLABLE_STATUSES.has(status) && !(Number(booking.totalAmount ?? 0) > 0);
// Approved documents (or an operations return) unlock the completion step —
// cargo + shipment day, the customer's post-clearance move. Mirrors the
// statuses completeMine accepts on the API.
const canComplete =
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
return (
<PageShell>
<Button
@@ -181,6 +211,18 @@ export default function ShippingLineBookingDetailPage() {
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
)}
{canComplete && (
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Button>
)}
</Group>
</Group>
@@ -231,6 +273,16 @@ export default function ShippingLineBookingDetailPage() {
Your documents are with Operations for review. You can still
open them, and replace any that come back with a query.
</Alert>
) : status === "OPERATION_REQUEST_PENDING" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
Your booking is complete and with Operations for review. The
charge has been recorded on your credit account.
</Alert>
) : status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="orange" radius="md" icon={<AlertCircle size={18} />}>
Operations returned your booking request for changes.
Resubmit it with an updated shipment day or cargo.
</Alert>
) : (
<Alert
color="teal"
@@ -238,21 +290,37 @@ export default function ShippingLineBookingDetailPage() {
icon={<CheckCircle2 size={18} />}
>
Your documents are approved.
{status === "CLEARANCE_READY" &&
" Complete the booking with your cargo and shipment day to proceed."}
</Alert>
)}
<Button
color={actionNeeded ? "red" : "edr-green"}
variant={wantsUpload ? "filled" : "light"}
radius="md"
w="fit-content"
leftSection={
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
}
onClick={() => setDocsOpen(true)}
>
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
<Group gap="sm">
<Button
color={actionNeeded ? "red" : "edr-green"}
variant={wantsUpload ? "filled" : "light"}
radius="md"
w="fit-content"
leftSection={
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
}
onClick={() => setDocsOpen(true)}
>
{DOC_STATE_ACTION_LABEL[docState]}
</Button>
{canComplete && (
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Button>
)}
</Group>
</Stack>
</SectionCard>
</Tabs.Panel>
@@ -297,6 +365,26 @@ export default function ShippingLineBookingDetailPage() {
: "Not scheduled yet"
}
/>
<DetailRow
label="Train"
value={
matchedTrains.length
? matchedTrains
.map(
(t) =>
`${t.trainNumber ?? t.reference ?? "Train"} — departs ${new Date(
t.scheduledDepartureDate,
).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
)
.join(" · ")
: "No train assigned for this lane and day yet"
}
/>
</Stack>
</SectionCard>
}
@@ -314,6 +402,16 @@ export default function ShippingLineBookingDetailPage() {
: "—"
}
/>
{/* Set at completion — the charge sits on the line's credit
account (pay-later), so no pay button follows it. */}
{Number(booking.totalAmount ?? 0) > 0 && (
<DetailRow
label="Amount (on credit)"
value={`${Number(booking.totalAmount).toLocaleString()} ${
booking.paymentCurrency ?? ""
}`.trim()}
/>
)}
</Stack>
</SectionCard>
}
@@ -326,6 +424,12 @@ export default function ShippingLineBookingDetailPage() {
onClose={() => setDocsOpen(false)}
/>
<ShippingLineCompleteModal
booking={completeOpen ? booking : null}
onClose={() => setCompleteOpen(false)}
onCompleted={() => setCompleteOpen(false)}
/>
{/* Cancelling is irreversible, so it asks first rather than firing on the
button press. The reason is optional but recorded. */}
<Modal

View File

@@ -22,6 +22,7 @@ import {
FileText,
MoreVertical,
Package,
PackageCheck,
Plus,
Upload,
} from "lucide-react";
@@ -213,6 +214,21 @@ export default function ShippingLineBookingsPage() {
{DOC_STATE_ACTION_LABEL[state]}
</Menu.Item>
)}
{/* Approved documents (or an operations return) unlock the
completion step — it lives on the detail page. */}
{(booking.status === "CLEARANCE_READY" ||
booking.status === "OPERATION_CHANGES_REQUESTED") && (
<Menu.Item
leftSection={<PackageCheck size={15} />}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}`)
}
>
{booking.status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
: "Complete booking"}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>

View File

@@ -0,0 +1,372 @@
import {
ActionIcon,
Alert,
Box,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CalendarDays,
PackageCheck,
Plus,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
shippingLineBookingsService,
type CompleteBookingContainerLine,
type ShippingLineBooking,
} from "@/services/shipping-line-bookings.service";
const CURRENCIES = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
interface ContainerLineDraft {
containerTypeId: string | null;
quantity: number | string;
vgmPerUnitTons: number | string;
}
const EMPTY_LINE: ContainerLineDraft = {
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 0,
};
/**
* Complete an approved shipping-line booking — the step a customer does after
* clearance: enter the cargo and the binding shipment day. The server prices
* the booking off the line's negotiated rates and puts the charge on the
* credit ledger (pay-later), so no payment step follows here.
*/
export default function ShippingLineCompleteModal({
booking,
onClose,
onCompleted,
}: {
/** The booking to complete, or null when the modal is closed. */
booking: ShippingLineBooking | null;
onClose: () => void;
onCompleted: (booking: ShippingLineBooking) => void;
}) {
const queryClient = useQueryClient();
const opened = Boolean(booking);
const bookingId = booking?.id ?? "";
const isContainer = (booking?.freightType ?? "CONTAINER") === "CONTAINER";
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [currency, setCurrency] = useState<string>("ETB");
const [lines, setLines] = useState<ContainerLineDraft[]>([{ ...EMPTY_LINE }]);
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const [cargoWeightTons, setCargoWeightTons] = useState<number | string>(0);
const [cargoFreeText, setCargoFreeText] = useState("");
// Fresh sheet each open, prefilled with the day picked at initiate (if any).
useEffect(() => {
if (opened && booking) {
setScheduledDate(
booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: null,
);
setCurrency(booking.paymentCurrency ?? "ETB");
setLines([{ ...EMPTY_LINE }]);
setCargoTypeId(null);
setCargoWeightTons(0);
setCargoFreeText("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, bookingId]);
const referenceQuery = useQuery({
queryKey: ["shipping-line-reference-data"],
queryFn: shippingLineBookingsService.referenceData,
enabled: opened,
});
// Only schedule-backed days are offered — same rule the server enforces.
const daysQuery = useQuery({
queryKey: ["shipping-line-bookings", bookingId, "available-days"],
queryFn: () => shippingLineBookingsService.availableDays(bookingId),
enabled: opened && Boolean(bookingId),
});
const completeMutation = useMutation({
mutationFn: () => {
const payload = isContainer
? {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
containers: lines
.filter((l) => l.containerTypeId && Number(l.quantity) > 0)
.map(
(l): CompleteBookingContainerLine => ({
containerTypeId: l.containerTypeId!,
quantity: Number(l.quantity),
vgmPerUnitTons: Number(l.vgmPerUnitTons) || 0,
}),
),
}
: {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
cargoTypeId: cargoTypeId!,
cargoWeightTons: Number(cargoWeightTons),
};
return shippingLineBookingsService.complete(bookingId, payload);
},
onSuccess: (updated) => {
void queryClient.invalidateQueries({
queryKey: ["shipping-line-bookings"],
});
onCompleted(updated);
},
});
const containerTypeOptions = useMemo(
() =>
(referenceQuery.data?.containerTypes ?? []).map((ct) => ({
value: ct.id,
label: ct.sizeFt ? `${ct.label} (${ct.sizeFt}ft)` : ct.label,
})),
[referenceQuery.data],
);
// Grouping headers are rows other rows point at via parentGroupId — only
// leaves are bookable cargo.
const cargoTypeOptions = useMemo(() => {
const all = referenceQuery.data?.cargoTypes ?? [];
const parents = new Set(
all.map((c) => c.parentGroupId).filter((id): id is string => Boolean(id)),
);
return all
.filter((c) => !parents.has(c.id))
.map((c) => ({ value: c.id, label: c.name }));
}, [referenceQuery.data]);
const dayOptions = useMemo(
() =>
(daysQuery.data?.days ?? []).map((day) => ({
value: day,
label: new Date(day).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
})),
[daysQuery.data],
);
const validCargo = isContainer
? lines.some((l) => l.containerTypeId && Number(l.quantity) > 0)
: Boolean(cargoTypeId) && Number(cargoWeightTons) > 0;
const canSubmit = Boolean(scheduledDate) && validCargo;
const loading = referenceQuery.isLoading || daysQuery.isLoading;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
radius="md"
title={
<Box>
<Text fw={700} fz={16}>
Complete booking
</Text>
<Text fz={12} c="dimmed">
Your documents are approved enter the cargo and shipment day. The
charge goes on your credit account.
</Text>
</Box>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{loading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<Stack gap="md">
{dayOptions.length === 0 ? (
<Alert color="yellow" radius="md" icon={<AlertCircle size={16} />}>
No departures are currently open on this route. Please check back
or contact Operations.
</Alert>
) : (
<Select
label="Shipment day"
description="Only days with an open train departure on your route are offered."
placeholder="Pick the shipment day"
withAsterisk
searchable
leftSection={<CalendarDays size={16} />}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
comboboxProps={{ withinPortal: true }}
/>
)}
{isContainer ? (
<Stack gap="xs">
<Text fz={13} fw={600}>
Containers
</Text>
{lines.map((line, index) => (
<Group key={index} gap="xs" align="flex-end" wrap="nowrap">
<Select
label={index === 0 ? "Container type" : undefined}
placeholder="Type"
searchable
style={{ flex: 2 }}
data={containerTypeOptions}
value={line.containerTypeId}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, containerTypeId: v } : l,
),
)
}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label={index === 0 ? "Quantity" : undefined}
min={1}
style={{ flex: 1 }}
value={line.quantity}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, quantity: v } : l,
),
)
}
/>
<NumberInput
label={index === 0 ? "VGM / unit (tons)" : undefined}
min={0}
decimalScale={3}
style={{ flex: 1 }}
value={line.vgmPerUnitTons}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, vgmPerUnitTons: v } : l,
),
)
}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
disabled={lines.length === 1}
onClick={() =>
setLines((prev) => prev.filter((_, i) => i !== index))
}
aria-label="Remove line"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="compact-sm"
w="fit-content"
leftSection={<Plus size={14} />}
onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}
>
Add container line
</Button>
</Stack>
) : (
<Group grow align="flex-start" gap="sm">
<Select
label="Cargo type"
placeholder="Select cargo..."
withAsterisk
searchable
data={cargoTypeOptions}
value={cargoTypeId}
onChange={setCargoTypeId}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label="Total weight (tons)"
withAsterisk
min={0}
decimalScale={3}
value={cargoWeightTons}
onChange={setCargoWeightTons}
/>
</Group>
)}
<Group grow align="flex-start" gap="sm">
<Select
label="Billing currency"
data={CURRENCIES}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
comboboxProps={{ withinPortal: true }}
/>
<Textarea
label="Cargo description (optional)"
placeholder="What the shipment carries"
autosize
minRows={1}
maxRows={3}
maxLength={500}
value={cargoFreeText}
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
/>
</Group>
{completeMutation.isError && (
<Alert color="red" icon={<AlertCircle size={16} />}>
{(completeMutation.error as Error)?.message ??
"Could not complete the booking."}
</Alert>
)}
<Group justify="flex-end" mt="sm" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
loading={completeMutation.isPending}
disabled={!canSubmit}
onClick={() => completeMutation.mutate()}
>
Complete booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,17 +1,129 @@
import { Home } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
import {
Badge,
Card,
Center,
Group,
Loader,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock, TrainFront } from "lucide-react";
import {
shippingLineBookingsService,
type ShippingLineTrain,
} from "@/services/shipping-line-bookings.service";
const STATUS_COLOR: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "teal",
};
function TrainCard({ train }: { train: ShippingLineTrain }) {
const departure = new Date(train.scheduledDepartureDate);
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Stack gap={4}>
<Group gap="xs">
<TrainFront size={16} />
<Text fw={600} fz={14}>
{train.trainNumber ?? train.reference ?? "Train"}
</Text>
{train.reference && train.trainNumber ? (
<Text fz={12} c="dimmed">
{train.reference}
</Text>
) : null}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[train.status] ?? "gray"}
>
{train.status}
</Badge>
</Group>
<Group gap={6}>
<Text fz={13}>{train.originLabel}</Text>
<ArrowRight size={13} />
<Text fz={13}>{train.destinationLabel}</Text>
</Group>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={6}>
<CalendarClock size={14} />
<Text fz={13} fw={500}>
{departure.toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</Group>
<Text fz={12} c="dimmed">
Departs{" "}
{departure.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Stack>
</Group>
</Card>
);
}
/**
* Shipping-line home / dashboard. Deliberately separate from the customer
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
* profiles and no contracts, so almost none of that page's data applies.
*
* Lists the train departures dedicated to this shipping line — those trains
* are hidden from customers, so this page (and the booking detail's lane/day
* match) is where the line sees them.
*/
export default function ShippingLineHomePage() {
const trainsQuery = useQuery({
queryKey: ["shipping-line-my-trains"],
queryFn: shippingLineBookingsService.myTrains,
});
const trains = trainsQuery.data ?? [];
return (
<ShippingLinePlaceholder
title="Home"
description="Overview of your shipping-line activity."
icon={<Home size={28} className="text-slate-300" />}
/>
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>Home</Title>
<Text c="dimmed" size="sm">
Overview of your shipping-line activity.
</Text>
</Stack>
<Stack gap="sm">
<Text fw={600} fz={15}>
Your trains
</Text>
{trainsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : trains.length === 0 ? (
<Card withBorder radius="md" py={48}>
<Stack align="center" gap="xs">
<TrainFront size={28} className="text-slate-300" />
<Text c="dimmed" size="sm">
No trains have been assigned to you yet.
</Text>
</Stack>
</Card>
) : (
trains.map((train) => <TrainCard key={train.id} train={train} />)
)}
</Stack>
</Stack>
);
}

View File

@@ -10,8 +10,9 @@ import {
Stack,
Text,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, MapPin, Plus } from "lucide-react";
import { AlertCircle, CalendarDays, MapPin, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -51,6 +52,7 @@ export default function ShippingLineInitiateModal({
);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<string>("CONTAINER");
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
// Fresh sheet each time it opens.
useEffect(() => {
@@ -59,6 +61,7 @@ export default function ShippingLineInitiateModal({
setDestinationYardId(null);
setServiceTypeId(null);
setFreightType("CONTAINER");
setScheduledDate(null);
}
}, [opened]);
@@ -74,6 +77,7 @@ export default function ShippingLineInitiateModal({
routeId: selectedRoute!.id,
serviceTypeId: serviceTypeId ?? undefined,
freightType,
scheduledDate: scheduledDate ?? undefined,
}),
onSuccess: (booking) => {
void queryClient.invalidateQueries({
@@ -214,6 +218,20 @@ export default function ShippingLineInitiateModal({
comboboxProps={{ withinPortal: true }}
/>
{/* Picked up front, unlike the customer flow — a shipping line has no
later operation-request step to choose its shipment day at. */}
<DatePickerInput
label="Scheduled date"
placeholder="Pick the shipment day"
withAsterisk
minDate={new Date().toISOString().slice(0, 10)}
leftSection={<CalendarDays size={16} />}
value={scheduledDate}
onChange={(v) => setScheduledDate(v ?? null)}
radius="md"
popoverProps={{ withinPortal: true }}
/>
{/* Only services that do NOT bundle customs are offered — the API
filters them and rejects the rest. If none are configured the
field says so rather than vanishing, which would read as a
@@ -254,7 +272,7 @@ export default function ShippingLineInitiateModal({
radius="md"
leftSection={<Plus size={16} />}
loading={initiateMutation.isPending}
disabled={!selectedRoute}
disabled={!selectedRoute || !scheduledDate}
onClick={() => initiateMutation.mutate()}
>
Initiate booking

View File

@@ -12,6 +12,7 @@ export interface LastMileRequest {
requestedContainerNumbers?: string[] | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
signerDisplayName?: string | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
@@ -46,6 +47,12 @@ export const lastMileRequestsService = {
return data.data ?? data;
},
/** The booking's requests, newest first — links the stored LM contract. */
listForBooking: async (bookingId: string): Promise<LastMileRequest[]> => {
const { data } = await client.get(L.BY_BOOKING(bookingId));
return data.data ?? data;
},
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
submit: async (
id: string,

View File

@@ -37,6 +37,41 @@ export interface ShippingLineRouteOption {
export interface ShippingLineReferenceData {
routes: ShippingLineRouteOption[];
serviceTypes: { id: string; name: string }[];
/** For the completion form — what ships in a CONTAINER booking. */
containerTypes: {
id: string;
label: string;
sizeFt: number | null;
isReefer: boolean;
}[];
/**
* For the completion form — what ships in a BULK booking. Rows with a
* `parentGroupId` are leaf types; rows without may be grouping headers.
*/
cargoTypes: {
id: string;
name: string;
parentGroupId: string | null;
unitOfMeasure: string | null;
}[];
}
/**
* A train departure dedicated to the signed-in shipping line. Hidden from
* customers server-side; `/my-trains` is the only portal read that returns it.
*/
export interface ShippingLineTrain {
id: string;
reference: string | null;
trainNumber: string | null;
status: string;
direction: string | null;
scheduledDepartureDate: string;
scheduledArrivalDate: string | null;
originYardId: string;
originLabel: string;
destinationYardId: string;
destinationLabel: string;
}
/**
@@ -47,6 +82,31 @@ export interface InitiateShippingLineBookingPayload {
routeId: string;
serviceTypeId?: string;
freightType?: string;
/** Intended shipment day (YYYY-MM-DD), picked up front by the shipping line. */
scheduledDate?: string;
}
/** One container line of a CONTAINER completion. */
export interface CompleteBookingContainerLine {
containerTypeId: string;
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}
/**
* Completion payload — the cargo and the binding shipment day, the two things
* `initiate` leaves empty. CONTAINER bookings send `containers`; BULK ones
* send `cargoTypeId` + `cargoWeightTons`.
*/
export interface CompleteShippingLineBookingPayload {
scheduledDate: string;
paymentCurrency?: string;
containers?: CompleteBookingContainerLine[];
cargoTypeId?: string;
cargoWeightTons?: number;
cargoFreeText?: string;
}
/**
@@ -73,11 +133,40 @@ export const shippingLineBookingsService = {
return data.data ?? data;
},
/** Train departures dedicated to the signed-in shipping line, soonest first. */
myTrains: async (): Promise<ShippingLineTrain[]> => {
const { data } = await client.get(`${BASE}/my-trains`);
return data.data ?? data;
},
getById: async (id: string): Promise<ShippingLineBooking> => {
const { data } = await client.get(`${BASE}/${id}`);
return data.data ?? data;
},
/**
* Days with an open departure that can carry this booking — for the
* completion form's shipment-day picker.
*/
availableDays: async (id: string): Promise<{ days: string[] }> => {
const { data } = await client.get(`${BASE}/${id}/available-days`);
return data.data ?? data;
},
/**
* Complete an approved (CLEARANCE_READY) booking: cargo + shipment day.
* The server prices it off the line's negotiated rates, records the charge
* on the credit ledger (pay-later — no invoice is issued here) and moves the
* booking to OPERATION_REQUEST_PENDING for Operations to review.
*/
complete: async (
id: string,
payload: CompleteShippingLineBookingPayload,
): Promise<ShippingLineBooking> => {
const { data } = await client.post(`${BASE}/${id}/complete`, payload);
return data.data ?? data;
},
/**
* Cancel one of the signed-in shipping line's own bookings. Only accepted
* before the booking is priced — the server enforces the same rule.