mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user