mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #1279 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -36,6 +36,8 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
|
||||
import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
@@ -317,6 +319,24 @@ const App = () => {
|
||||
OR'd across both keys so a user with just one still gets in; each
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
<Route
|
||||
path="shipping-lines"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||
<ShippingLineCompaniesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipping-line-credits"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.shippingLineCredits.view}
|
||||
>
|
||||
<ShippingLineCreditsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
// Booking-level flags OR any container line carrying a count — the flag can
|
||||
// lag the lines (per-line opt-ins), so either alone must light the tile.
|
||||
const isHazardous =
|
||||
booking.isHazardous ||
|
||||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
const isReefer =
|
||||
booking.isReefer ||
|
||||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
|
||||
const showHandlingColumns = containers.some(
|
||||
(c) =>
|
||||
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
value={isHazardous ? "Yes" : "No"}
|
||||
highlight={isHazardous}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Refrigerated"
|
||||
value={isReefer ? "Yes" : "No"}
|
||||
highlight={isReefer}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Handling that changes how the yard treats the shipment is flagged
|
||||
loudly, not buried in the grid. */}
|
||||
{(isHazardous || isReefer) && (
|
||||
<Box mt="sm">
|
||||
{isHazardous && (
|
||||
<Badge color="red" variant="filled" radius="sm" mr={8}>
|
||||
Hazardous cargo
|
||||
</Badge>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Badge color="blue" variant="filled" radius="sm">
|
||||
Refrigerated cargo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
<Table.Th>Container type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
|
||||
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
</Table.Td>
|
||||
<Table.Td>{c.quantity}</Table.Td>
|
||||
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.hazardousQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="red" size="sm">
|
||||
{c.hazardousQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.reeferQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="blue" size="sm">
|
||||
{c.reeferQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
HandCoins,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -70,6 +71,18 @@ export const buildSidebarSections = (
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Lines",
|
||||
href: "/dashboard/shipping-lines",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.shippingLines.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Line Credits",
|
||||
href: "/dashboard/shipping-line-credits",
|
||||
icon: <HandCoins />,
|
||||
permission: FREIGHT_PERMS.shippingLineCredits.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
|
||||
@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
|
||||
current[name] ? { ...current, [name]: "" } : current,
|
||||
);
|
||||
setValues((current) => {
|
||||
// Mantine fires onChange even when the same option is re-picked, and the
|
||||
// cascades below clear dependent answers (yards, unit, scope). Re-picking
|
||||
// an unchanged value must be a no-op, or an untouched direction silently
|
||||
// wipes the yard pair and the submit fails with "origin/destination
|
||||
// missing" data the admin did fill in.
|
||||
if (current[name] === value) return current;
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
@@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
next.shippingLineCompanyId = "";
|
||||
next.shippingLineRateKind = "";
|
||||
next.shippingLineCargoKind = "";
|
||||
next.appliesTo = "";
|
||||
next.trigger = "";
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
|
||||
// the legal units for a shipping-line rate, exactly as appliesTo and
|
||||
// cargoKind do on the customer form.
|
||||
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
if (name === "shippingLineRateKind") {
|
||||
next.shippingLineCargoKind = "";
|
||||
next.trigger = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{field.description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{field.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
@@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
const computedSelect = field.computeValue
|
||||
? String(field.computeValue(values) ?? "")
|
||||
: undefined;
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -501,9 +552,18 @@ const RuleEngineFormDialog = ({
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computedSelect !== undefined
|
||||
}
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Ban, Check, HandCoins, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatMoney } from "@/components/customers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreditInvoiceActionType,
|
||||
CreditInvoicePendingAction,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
/** The slice of an invoice row the actions need — both list pages have it. */
|
||||
export interface CreditInvoiceActionTarget {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
}
|
||||
|
||||
/** Statuses an offline payment can still be recorded against. */
|
||||
const MARK_PAID_STATUSES = new Set([
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PAYMENT_PROCESSING",
|
||||
"PARTIALLY_PAID",
|
||||
"OVERDUE",
|
||||
]);
|
||||
|
||||
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
|
||||
MARK_PAID: "Mark paid",
|
||||
CANCEL: "Cancel invoice",
|
||||
};
|
||||
|
||||
export interface CreditInvoiceActionsProps {
|
||||
invoice: CreditInvoiceActionTarget;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
|
||||
* invoice list. Gated purely by permission: the request grants raise
|
||||
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
|
||||
* the holder's own included. Renders only the buttons the signed-in user's
|
||||
* grants allow; the API enforces the same gates server-side.
|
||||
*/
|
||||
export default function CreditInvoiceActions({
|
||||
invoice,
|
||||
pendingAction,
|
||||
}: CreditInvoiceActionsProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const canRequestPaid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
);
|
||||
const canRequestCancel = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
);
|
||||
const canApprove = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
);
|
||||
const canReject = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
);
|
||||
|
||||
const [requestAction, setRequestActionModal] =
|
||||
useState<CreditInvoiceActionType | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [paymentReference, setPaymentReference] = useState("");
|
||||
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
|
||||
const [decisionNote, setDecisionNote] = useState("");
|
||||
|
||||
const closeRequest = () => {
|
||||
setRequestActionModal(null);
|
||||
setReason("");
|
||||
setPaymentReference("");
|
||||
};
|
||||
const closeDecide = () => {
|
||||
setDecideApprove(null);
|
||||
setDecisionNote("");
|
||||
};
|
||||
|
||||
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
|
||||
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeRequest();
|
||||
toast({
|
||||
title: "Request submitted",
|
||||
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not submit request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
|
||||
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeDecide();
|
||||
toast({
|
||||
title: variables.approve ? "Request approved" : "Request rejected",
|
||||
description: variables.approve
|
||||
? pendingAction?.action === "MARK_PAID"
|
||||
? "The offline payment was recorded; the invoice and its credits are now paid."
|
||||
: "The invoice was cancelled; its credits returned to the unbilled pool."
|
||||
: "The request was rejected and nothing was changed.",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not decide request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let body = null;
|
||||
if (pendingAction) {
|
||||
body = (
|
||||
<Stack gap={6} py={4}>
|
||||
<Badge variant="light" color="orange" title={pendingAction.reason}>
|
||||
{ACTION_LABEL[pendingAction.action]} — awaiting approval
|
||||
</Badge>
|
||||
{canApprove || canReject ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canApprove ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={12} />}
|
||||
onClick={() => setDecideApprove(true)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<X size={12} />}
|
||||
onClick={() => setDecideApprove(false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
const showMarkPaid =
|
||||
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
|
||||
const showCancel =
|
||||
canRequestCancel &&
|
||||
invoice.status !== "CANCELLED" &&
|
||||
invoice.status !== "PAID" &&
|
||||
invoice.status !== "REFUNDED" &&
|
||||
Number(invoice.paidAmount) === 0;
|
||||
body =
|
||||
!showMarkPaid && !showCancel ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{showMarkPaid ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<HandCoins size={12} />}
|
||||
onClick={() => setRequestActionModal("MARK_PAID")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
) : null}
|
||||
{showCancel ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Ban size={12} />}
|
||||
onClick={() => setRequestActionModal("CANCEL")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
|
||||
{/* Maker: raise the request. */}
|
||||
<Modal
|
||||
opened={requestAction !== null}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
requestAction
|
||||
? `${ACTION_LABEL[requestAction]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{requestAction === "MARK_PAID"
|
||||
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
|
||||
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
|
||||
</Text>
|
||||
{requestAction === "MARK_PAID" ? (
|
||||
<TextInput
|
||||
label="Payment reference"
|
||||
description="Bank slip / transfer number, if any."
|
||||
value={paymentReference}
|
||||
onChange={(e) => setPaymentReference(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
minRows={2}
|
||||
placeholder={
|
||||
requestAction === "MARK_PAID"
|
||||
? "Paid by bank transfer, slip #…"
|
||||
: "Raised in error / rebilling with corrections…"
|
||||
}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isRequesting}
|
||||
disabled={reason.trim().length < 3}
|
||||
onClick={() =>
|
||||
requestAction &&
|
||||
submitRequest({
|
||||
invoiceId: invoice.id,
|
||||
action: requestAction,
|
||||
reason: reason.trim(),
|
||||
paymentReference: paymentReference.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit for approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Checker: decide the request. */}
|
||||
<Modal
|
||||
opened={decideApprove !== null}
|
||||
onClose={closeDecide}
|
||||
title={
|
||||
pendingAction
|
||||
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{pendingAction ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Requested reason:{" "}
|
||||
</Text>
|
||||
{pendingAction.reason}
|
||||
</Text>
|
||||
{pendingAction.paymentReference ? (
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Payment reference:{" "}
|
||||
</Text>
|
||||
{pendingAction.paymentReference}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
{decideApprove && pendingAction ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingAction.action === "MARK_PAID"
|
||||
? `Approving records ${formatMoney(
|
||||
Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)} as paid offline and settles the invoice's credits.`
|
||||
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
|
||||
</Text>
|
||||
) : null}
|
||||
<Textarea
|
||||
label={decideApprove ? "Note (optional)" : "Rejection note"}
|
||||
withAsterisk={!decideApprove}
|
||||
minRows={2}
|
||||
value={decisionNote}
|
||||
onChange={(e) => setDecisionNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeDecide}
|
||||
disabled={isDeciding}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color={decideApprove ? "edr-green" : "red"}
|
||||
loading={isDeciding}
|
||||
disabled={!decideApprove && !decisionNote.trim()}
|
||||
onClick={() =>
|
||||
pendingAction &&
|
||||
decideApprove !== null &&
|
||||
submitDecision({
|
||||
approvalId: pendingAction.id,
|
||||
approve: decideApprove,
|
||||
note: decisionNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{decideApprove ? "Approve & execute" : "Reject request"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ResetChannel,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* The carrier integration is domestic-only: anything else is queued and
|
||||
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||
*/
|
||||
function isDomesticPhone(rawPhone: string): boolean {
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||
? `+251${digits.replace(/^0+/, "")}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized);
|
||||
}
|
||||
|
||||
export interface ResendActivationActionProps {
|
||||
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a shipping line's activation link.
|
||||
*
|
||||
* The same single-use link registration sends: the carrier opens it and picks
|
||||
* their own password, so no credential is ever shown to or handled by staff.
|
||||
* Needed whenever the original send failed, expired (24h), or never arrived.
|
||||
*/
|
||||
export default function ResendActivationAction({
|
||||
shippingLine,
|
||||
}: ResendActivationActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send activation link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
const phoneUsable =
|
||||
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||
const channelMissing = channel === "phone" && !phoneUsable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Resend activation link"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to {shippingLine.name}. They choose
|
||||
their own password — you will not see it. The link expires in 24
|
||||
hours, and sending a new one invalidates nothing they haven't
|
||||
already used.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label="Send the link via"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={shippingLine.email}
|
||||
/>
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!shippingLine.phoneNumber
|
||||
? "No phone number on this account"
|
||||
: !phoneUsable
|
||||
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||
: shippingLine.phoneNumber
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{channelMissing ? (
|
||||
<Alert color="yellow" variant="light" p="sm">
|
||||
<Text size="sm">
|
||||
This account has no number the SMS gateway can reach. Send the
|
||||
link by email instead.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||
>
|
||||
Send activation link
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -28,6 +28,48 @@ export const QUERY_KEYS = {
|
||||
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
ROOT: ["shipping-line-companies"] as const,
|
||||
list: (page: number, limit: number) =>
|
||||
["shipping-line-companies", "list", page, limit] as const,
|
||||
byId: (id: string) =>
|
||||
["shipping-line-companies", "detail", id] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
ROOT: ["shipping-line-credits"] as const,
|
||||
invoices: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"invoices",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
summary: (shippingLineId?: string) =>
|
||||
["shipping-line-credits", "summary", shippingLineId ?? "all"] as const,
|
||||
list: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"list",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
},
|
||||
|
||||
CUSTOMERS: {
|
||||
ROOT: ["customers"] as const,
|
||||
stats: ["customers", "stats"] as const,
|
||||
|
||||
@@ -77,6 +77,33 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
/**
|
||||
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
|
||||
* rule-engine's pricing lookup list (a code/label bookings reference).
|
||||
*/
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
BASE: "/shipping-line-companies",
|
||||
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
|
||||
RESEND_ACTIVATION: (id: string) =>
|
||||
`/shipping-line-companies/${id}/resend-activation`,
|
||||
},
|
||||
|
||||
/** Finance's view of what shipping lines owe (use now, pay later). */
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
BASE: "/shipping-line-credits",
|
||||
SUMMARY: "/shipping-line-credits/summary",
|
||||
INVOICE: "/shipping-line-credits/invoice",
|
||||
INVOICES: "/shipping-line-credits/invoices",
|
||||
MARK_PAID_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/mark-paid-request`,
|
||||
CANCEL_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/cancel-request`,
|
||||
APPROVE_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/approve`,
|
||||
REJECT_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/reject`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
STATS: "/companies/stats",
|
||||
|
||||
@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { api } from "@/services/api";
|
||||
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
|
||||
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping lines a rate can be scoped to. Only ACTIVE lines are offered — the
|
||||
* API refuses a rate filed against a suspended one, so listing them would only
|
||||
* produce an error on submit. Sorted by name so the picker is scannable.
|
||||
*/
|
||||
export const useShippingLineCompanyOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("shipping-line-companies", {}),
|
||||
// One page well past the number of carriers on the corridor; the picker
|
||||
// needs the whole list, not a page of it.
|
||||
queryFn: () => shippingLineCompaniesService.list(1, 200),
|
||||
enabled,
|
||||
select: (page: PaginatedShippingLineCompanies) =>
|
||||
page.items
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({
|
||||
label: line.scacCode ? `${line.name} (${line.scacCode})` : line.name,
|
||||
value: line.id,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
|
||||
@@ -123,6 +123,21 @@ export const FREIGHT_PERMS = {
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
update: "edr_freight_app:shipping_lines:update",
|
||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||
},
|
||||
shippingLineCredits: {
|
||||
view: "edr_freight_app:shipping_line_credits:view",
|
||||
invoice: "edr_freight_app:shipping_line_credits:invoice",
|
||||
cancel: "edr_freight_app:shipping_line_credits:cancel",
|
||||
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
|
||||
@@ -138,9 +138,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
// Querying a document is only possible while the booking is actually in
|
||||
// review — the server enforces exactly that (reviewDocument asserts
|
||||
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
|
||||
// only ever produce a 400.
|
||||
//
|
||||
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
|
||||
// so a non-customs booking (self-clearance, and every shipping-line booking)
|
||||
// never sets it and kept offering Query after Operations had finalized.
|
||||
const queriesLocked =
|
||||
Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)
|
||||
?.preClearanceFinalized,
|
||||
) ||
|
||||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
// Shipping-line credit invoices carry maker–checker actions (mark paid /
|
||||
// cancel). One batched lookup fetches the visible rows' pending requests.
|
||||
const creditInvoiceIds = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((inv) => inv.source === "shipping_line_credit")
|
||||
.map((inv) => inv.id),
|
||||
[rows],
|
||||
);
|
||||
const { data: pendingActions } = useQuery(
|
||||
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
|
||||
input: { invoiceIds: creditInvoiceIds },
|
||||
enabled: creditInvoiceIds.length > 0,
|
||||
}),
|
||||
);
|
||||
const pendingByInvoice = useMemo(
|
||||
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
|
||||
[pendingActions],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
// Only shipping-line credit invoices have manual maker–checker
|
||||
// actions; every other source settles through its own flow.
|
||||
if (inv.source !== "shipping_line_credit") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CreditInvoiceActions
|
||||
invoice={inv}
|
||||
pendingAction={pendingByInvoice.get(inv.id) ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[pendingByInvoice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useShippingLineCompanyOptions,
|
||||
useWagonTypeOptions,
|
||||
useYardOptions,
|
||||
type YardOption,
|
||||
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
|
||||
values: Record<string, unknown>,
|
||||
end: "origin" | "destination",
|
||||
): { label: string; value: string }[] => {
|
||||
// A shipping-line rate names its shape in its own fields and is always
|
||||
// import; map it onto the appliesTo/direction pair the rest of this function
|
||||
// reads so the country narrowing is shared rather than duplicated.
|
||||
if (values.isShippingLineRate === true) {
|
||||
if (!values.shippingLineCompanyId) return [];
|
||||
const isBase = values.shippingLineRateKind === "BASE";
|
||||
values = {
|
||||
...values,
|
||||
appliesTo: isBase
|
||||
? String(values.shippingLineCargoKind ?? "")
|
||||
: "OTHER",
|
||||
tradeDirection: "IMPORT",
|
||||
};
|
||||
}
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(false, usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const usesShippingLineField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
||||
);
|
||||
const {
|
||||
data: shippingLineOptions,
|
||||
isLoading: shippingLineOptionsLoading,
|
||||
} = useShippingLineCompanyOptions(usesShippingLineField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
const usesYardField = Boolean(
|
||||
@@ -390,6 +412,13 @@ const RuleEngineResourcePage = () => {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (field.name === "shippingLineCompanyId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: shippingLineOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
let payload = values;
|
||||
if (config.slug === "rates") {
|
||||
if (config.slug === "rates" && values.isShippingLineRate === true) {
|
||||
// A shipping-line rate asks its shape as "base freight vs surcharge" +
|
||||
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
|
||||
// customer rate, so translate here and drop the form-only fields. Always
|
||||
// import (the only direction a line ships) and always USD.
|
||||
const {
|
||||
isShippingLineRate: _toggle,
|
||||
shippingLineRateKind,
|
||||
shippingLineCargoKind,
|
||||
...rest
|
||||
} = values;
|
||||
void _toggle;
|
||||
const isBase = shippingLineRateKind === "BASE";
|
||||
payload = {
|
||||
...rest,
|
||||
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
|
||||
trigger: isBase ? "ALWAYS" : values.trigger,
|
||||
tradeDirection: "IMPORT",
|
||||
currency: "USD",
|
||||
};
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (config.slug === "rates") {
|
||||
// Base-freight categories have no surcharge trigger field — the engine
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
@@ -621,7 +682,11 @@ const RuleEngineResourcePage = () => {
|
||||
// ton·km, container = per km + distance band) and the currency stays as
|
||||
// chosen (birr or dollar). Everything else remains USD-only.
|
||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||
const { lastMileMode, ...rest } = values;
|
||||
// The shipping-line toggle is form-only — the API's whitelist rejects the
|
||||
// whole payload if it leaks through ("property isShippingLineRate should
|
||||
// not exist").
|
||||
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
|
||||
void _toggle;
|
||||
payload = {
|
||||
...rest,
|
||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||
@@ -934,6 +999,7 @@ const RuleEngineResourcePage = () => {
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesShippingLineField && shippingLineOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
|
||||
@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
filters: {
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* "true" = only shipping-line rates, "false" = only standard customer
|
||||
* rates. Sent as a string because tab filters go on the query string
|
||||
* verbatim.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A shipping-line rate: priced for one carrier's own bookings instead of for
|
||||
* every customer. The toggle drives the whole form — until a line is picked
|
||||
* there is nothing to configure, and the shape questions (base freight vs
|
||||
* surcharge, container vs bulk) are asked only after it is.
|
||||
*/
|
||||
const isShippingLineRate = (values: Record<string, unknown>) =>
|
||||
values.isShippingLineRate === true;
|
||||
|
||||
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
|
||||
const hasShippingLine = (values: Record<string, unknown>) =>
|
||||
isShippingLineRate(values) && Boolean(values.shippingLineCompanyId);
|
||||
|
||||
/**
|
||||
* What a shipping-line rate prices. Deliberately narrower than the customer
|
||||
* form's `appliesTo`: a line buys base rail freight (its own containers or
|
||||
* bulk) or a surcharge, and nothing else — intercity and first/last mile are
|
||||
* customer products.
|
||||
*/
|
||||
const SHIPPING_LINE_RATE_KINDS = [
|
||||
{ label: "Base freight", value: "BASE" },
|
||||
{ label: "Surcharge", value: "SURCHARGE" },
|
||||
];
|
||||
|
||||
/** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */
|
||||
const SHIPPING_LINE_CARGO_KINDS = [
|
||||
{ label: "Container", value: "CONTAINER" },
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
@@ -214,7 +253,15 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
// A shipping line's base freight is priced per leg exactly like a customer's;
|
||||
// its surcharges are route-scoped on the same triggers.
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
|
||||
values: Record<string, unknown>,
|
||||
cargoUnitOfMeasure = "",
|
||||
) => {
|
||||
// A shipping-line rate answers the same two questions under different names —
|
||||
// map them onto the shape the unit table is keyed by. Base freight for a line
|
||||
// is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger.
|
||||
if (isShippingLineRate(values)) {
|
||||
const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values;
|
||||
if (kind === "BASE") {
|
||||
if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return [];
|
||||
return allowedRateUnits(
|
||||
String(cargoKind),
|
||||
"ALWAYS",
|
||||
"",
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
if (kind === "SURCHARGE" && values.trigger) {
|
||||
return allowedRateUnits(
|
||||
"OTHER",
|
||||
String(values.trigger),
|
||||
String(values.cargoKind ?? ""),
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
// "All" and every shape tab show customer rates only — a shipping line's
|
||||
// negotiated price is its own list, not an extra row in the standard one.
|
||||
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "shipping-line",
|
||||
label: "Shipping line",
|
||||
filters: { isShippingLineRate: "true" },
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
isShippingLineRate: "false",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
// Blank on a standard customer rate; the owning carrier on a line rate.
|
||||
{
|
||||
id: "shippingLineCompany",
|
||||
header: "Shipping line",
|
||||
accessorKey: "shippingLineCompany",
|
||||
format: "entityLabel",
|
||||
},
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
// Base freight is priced per leg, so the route is what tells two otherwise
|
||||
@@ -879,6 +966,59 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
// ── Shipping line rate ────────────────────────────────────────────────
|
||||
// Flipping this on replaces the whole customer form: the only question
|
||||
// is which line, and the shape questions follow once it is answered.
|
||||
{
|
||||
name: "isShippingLineRate",
|
||||
label: "Shipping line rate",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.",
|
||||
// The owner is part of a rate's identity, so switching an existing rate
|
||||
// between customer and line pricing would silently re-target every
|
||||
// booking that prices off it. Create a new rate instead.
|
||||
disabledOnEdit: true,
|
||||
getInitialValue: (record) => Boolean(record.shippingLineCompanyId),
|
||||
},
|
||||
{
|
||||
name: "shippingLineCompanyId",
|
||||
label: "Shipping line",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which shipping line this rate is for",
|
||||
description:
|
||||
"Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.",
|
||||
disabledOnEdit: true,
|
||||
showIf: isShippingLineRate,
|
||||
},
|
||||
// What the line is buying. Asked only after a line is picked, so the form
|
||||
// stays a single question until then.
|
||||
{
|
||||
name: "shippingLineRateKind",
|
||||
label: "Rate type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_RATE_KINDS,
|
||||
placeholder: "Base freight or a surcharge?",
|
||||
showIf: hasShippingLine,
|
||||
// Not stored: base freight carries trigger ALWAYS, a surcharge anything else.
|
||||
getInitialValue: (record) =>
|
||||
!record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE",
|
||||
},
|
||||
// Container vs bulk — the line form asks this directly instead of folding
|
||||
// it into `appliesTo` the way the customer form does.
|
||||
{
|
||||
name: "shippingLineCargoKind",
|
||||
label: "Cargo kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_CARGO_KINDS,
|
||||
placeholder: "Is this rate for containers or bulk?",
|
||||
showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE",
|
||||
getInitialValue: (record) =>
|
||||
record.appliesTo === "BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "appliesTo",
|
||||
label: "Applies to",
|
||||
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_APPLIES_TO,
|
||||
description:
|
||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||
// Derived from the two questions above on a shipping-line rate.
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||
{
|
||||
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// The same trigger list for a shipping-line surcharge — a line incurs the
|
||||
// same charges a customer does (hazard, reefer, demurrage …), just at its
|
||||
// own negotiated price.
|
||||
{
|
||||
name: "trigger",
|
||||
label: "Surcharge trigger",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
)),
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
// as a locked field rather than hidden so the lane the yard pickers are
|
||||
// filtered by is visible.
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ label: "Import", value: "IMPORT" }],
|
||||
description: "Shipping line rates are import-only.",
|
||||
disabled: true,
|
||||
// No defaultValue: field names repeat across form variants and the
|
||||
// seeded initial value is shared, so defaulting here would pre-select
|
||||
// Import on the customer form's own direction field too. computeValue
|
||||
// pins IMPORT on submit and locks the input regardless.
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")),
|
||||
},
|
||||
// Bulk commodity for a shipping-line base-freight rate. Its unit of
|
||||
// measure decides the rate unit offered below — a counted commodity
|
||||
// (PER_ITEM) prices per item where a weighed one prices per ton.
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which bulk commodity this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "BULK",
|
||||
},
|
||||
// ── The leg this rate prices — base freight only ──────────────────────
|
||||
// Options are narrowed to the countries the direction allows (import
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** SCAC is 2-4 letters; the API enforces the same rule. */
|
||||
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
scacCode: string;
|
||||
imoNumber: string;
|
||||
bicCode: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormValues = {
|
||||
name: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
scacCode: "",
|
||||
imoNumber: "",
|
||||
bicCode: "",
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping line companies — carriers with their own portal login.
|
||||
*
|
||||
* Registration is staff-only: there is no self-signup. Staff never set a
|
||||
* password; the system emails (and texts, when the number is domestic) a
|
||||
* single-use activation link that the carrier uses to choose their own.
|
||||
*/
|
||||
export default function ShippingLineCompaniesPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
limit: pagination.pageSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const setField = (field: keyof FormValues) => (value: string) =>
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Mirrors the API's own validation, so the obvious mistakes are caught before
|
||||
// a round trip. The server still enforces all of it.
|
||||
const errors = {
|
||||
name: values.name.trim() ? null : "Company name is required",
|
||||
// Required, unlike a customer's: the activation link is sent here, so an
|
||||
// account without one could never be signed in to.
|
||||
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
|
||||
? null
|
||||
: "A valid email is required",
|
||||
scacCode:
|
||||
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
|
||||
? null
|
||||
: "SCAC must be 2-4 letters",
|
||||
};
|
||||
const isValid = !errors.name && !errors.email && !errors.scacCode;
|
||||
|
||||
const closeRegister = () => {
|
||||
setRegisterOpen(false);
|
||||
setValues(EMPTY_FORM);
|
||||
setTouched(false);
|
||||
};
|
||||
|
||||
const { mutate: register, isPending: isRegistering } = useMutation(
|
||||
api.shippingLineCompanies.register.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
closeRegister();
|
||||
toast({
|
||||
title: "Shipping line registered",
|
||||
description: result.activationSentTo
|
||||
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||
: // The account exists and is valid — only delivery failed, and the
|
||||
// link can be resent, so this is a warning rather than an error.
|
||||
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
|
||||
variant: result.activationSentTo ? undefined : "destructive",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not register shipping line",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Shipping line",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
<Ship size={18} strokeWidth={1.9} />
|
||||
</Box>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
{sl.name}
|
||||
</Text>
|
||||
{sl.scacCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
SCAC {sl.scacCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Mail size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" truncate>
|
||||
{sl.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{sl.phoneNumber ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Phone size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{sl.phoneNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "identifiers",
|
||||
header: "Identifiers",
|
||||
cell: ({ row }) => {
|
||||
const { imoNumber, bicCode } = row.original;
|
||||
if (!imoNumber && !bicCode) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
|
||||
{bicCode ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
BIC {bicCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={row.original.status === "active" ? "green" : "red"}
|
||||
>
|
||||
{row.original.status === "active" ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Registered",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ResendActivationAction shippingLine={row.original} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Lines"
|
||||
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
>
|
||||
Register shipping line
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No shipping lines registered yet."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load shipping lines.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={registerOpen}
|
||||
onClose={closeRegister}
|
||||
title="Register shipping line"
|
||||
centered
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
if (!isValid) return;
|
||||
register({
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
phoneNumber: values.phoneNumber.trim() || undefined,
|
||||
scacCode: values.scacCode.trim() || undefined,
|
||||
imoNumber: values.imoNumber.trim() || undefined,
|
||||
bicCode: values.bicCode.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
No password is set here. The shipping line receives a single-use
|
||||
activation link and chooses their own.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
placeholder="Ethiopian Shipping Lines"
|
||||
withAsterisk
|
||||
value={values.name}
|
||||
onChange={(e) => setField("name")(e.currentTarget.value)}
|
||||
error={touched ? errors.name : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="ops@example.com"
|
||||
description="The activation link is sent here."
|
||||
withAsterisk
|
||||
value={values.email}
|
||||
onChange={(e) => setField("email")(e.currentTarget.value)}
|
||||
error={touched ? errors.email : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251911223344"
|
||||
description="Ethiopian numbers also receive the link by SMS."
|
||||
value={values.phoneNumber}
|
||||
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="SCAC"
|
||||
placeholder="ESLK"
|
||||
value={values.scacCode}
|
||||
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
|
||||
error={touched ? errors.scacCode : null}
|
||||
/>
|
||||
<TextInput
|
||||
label="IMO number"
|
||||
placeholder="IMO9074729"
|
||||
value={values.imoNumber}
|
||||
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="BIC code"
|
||||
placeholder="ESLU"
|
||||
value={values.bicCode}
|
||||
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isRegistering}>
|
||||
Register & send link
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreditInvoice } from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
ISSUED: { label: "Issued", color: "orange" },
|
||||
PENDING: { label: "Pending", color: "orange" },
|
||||
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
|
||||
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
OVERDUE: { label: "Overdue", color: "red" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
REFUNDED: { label: "Refunded", color: "blue" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
|
||||
([value, meta]) => ({ value, label: meta.label }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Invoices minted from credit batches. The actions column is the shared
|
||||
* maker–checker component (also embedded on the Finance hub's invoice list):
|
||||
* finance requests mark-paid / cancel, a chief approves or rejects.
|
||||
*/
|
||||
export default function ShippingLineCreditInvoicesPanel() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
|
||||
api.shippingLineCredits.listInvoices.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const columns: ColumnDef<CreditInvoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm font-semibold text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{inv.shippingLineName ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
header: () => <span className={bookingTable.headerCell}>Issued</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "due",
|
||||
header: () => <span className={bookingTable.headerCell}>Due</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
header: () => <span className={bookingTable.headerCell}>Balance</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.balanceAmount ?? row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = INVOICE_STATUS_META[row.original.status] ?? {
|
||||
label: row.original.status,
|
||||
color: "gray",
|
||||
};
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<CreditInvoiceActions
|
||||
invoice={row.original}
|
||||
pendingAction={row.original.pendingAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={() => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credit invoices yet — generate one from the Credits tab."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Stack, Tabs } from "@mantine/core";
|
||||
import { HandCoins, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
|
||||
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
|
||||
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
|
||||
* FinanceHubPage convention): the credit ledger (select unbilled credits →
|
||||
* generate an invoice) and the invoices minted from it (maker–checker
|
||||
* mark-paid / cancel actions).
|
||||
*/
|
||||
export default function ShippingLineCreditsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab =
|
||||
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Line Credits"
|
||||
subtitle={
|
||||
activeTab === "invoices"
|
||||
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
|
||||
: "What each line owes — outstanding totals and the full credit ledger."
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
|
||||
Credits
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="credits" pt="lg">
|
||||
<ShippingLineCreditsPanel />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="invoices" pt="lg">
|
||||
<ShippingLineCreditInvoicesPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
FilterX,
|
||||
HandCoins,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_META: Record<
|
||||
ShippingLineCreditStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
UNBILLED: { label: "Unbilled", color: "orange" },
|
||||
BILLED: { label: "Billed", color: "blue" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Every shipping line's credits in one list — finance's landing view, styled
|
||||
* to match the booking-requests page. Summary cells total the current filter
|
||||
* scope (all lines by default); the selects narrow both cells and ledger.
|
||||
*/
|
||||
export default function ShippingLineCreditsPanel() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const canInvoice = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoice,
|
||||
);
|
||||
|
||||
// Selection for batch invoicing, kept as id → credit so it survives page
|
||||
// changes and can total itself. One invoice has one payer, so everything
|
||||
// selected must belong to the same shipping line — enforced here so the
|
||||
// API's rejection is never the first time staff hears about it.
|
||||
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
|
||||
new Map(),
|
||||
);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [dueInDays, setDueInDays] = useState<number | "">("");
|
||||
|
||||
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
|
||||
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
|
||||
const selectedTotal = selectedCredits.reduce(
|
||||
(sum, c) => sum + Number(c.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
const toggleSelected = (credit: ShippingLineCredit) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(credit.id)) next.delete(credit.id);
|
||||
else next.set(credit.id, credit);
|
||||
return next;
|
||||
});
|
||||
|
||||
const clearSelection = () => setSelected(new Map());
|
||||
|
||||
// ponytail: first 100 lines in the picker; server-side search when a real
|
||||
// deployment outgrows that.
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({
|
||||
value: sl.id,
|
||||
label: sl.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
|
||||
})),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.summary.queryOptions({
|
||||
input: { shippingLineId: shippingLineId ?? undefined },
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
data: ledger,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = ledger?.items ?? [];
|
||||
const total = ledger?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const clearFilters = () => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
};
|
||||
|
||||
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
|
||||
api.shippingLineCredits.generateInvoice.mutationOptions({
|
||||
onSuccess: (invoice) => {
|
||||
setInvoiceOpen(false);
|
||||
clearSelection();
|
||||
setDueInDays("");
|
||||
toast({
|
||||
title: `Invoice ${invoice.invoiceNumber} generated`,
|
||||
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not generate invoice",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
// A concurrent edit (someone else billed a selected credit) is the
|
||||
// usual cause — resync so stale rows drop out of the list.
|
||||
handleRefresh();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
|
||||
() => [
|
||||
...(canInvoice
|
||||
? [
|
||||
{
|
||||
id: "select",
|
||||
size: 40,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
|
||||
const credit = row.original;
|
||||
const selectable =
|
||||
credit.status === "UNBILLED" &&
|
||||
(selectedLineId === null ||
|
||||
credit.shippingLineCompanyId === selectedLineId);
|
||||
return (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected.has(credit.id)}
|
||||
disabled={!selectable}
|
||||
title={
|
||||
credit.status !== "UNBILLED"
|
||||
? "Only unbilled credits can be invoiced"
|
||||
: !selectable
|
||||
? "One invoice has one payer — selection already holds another line's credits"
|
||||
: undefined
|
||||
}
|
||||
onChange={() => toggleSelected(credit)}
|
||||
aria-label="Select credit for invoicing"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "shippingLine",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Shipping line</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const credit = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{credit.shippingLineCompany?.name ?? "—"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{credit.booking?.reference ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Description</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(Number(row.original.amount), row.original.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original.invoice;
|
||||
return inv ? (
|
||||
<span className="truncate font-mono text-xs text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
// Selection state drives the checkbox column's checked/disabled rendering.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[canInvoice, selected, selectedLineId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Total outstanding",
|
||||
value: summary
|
||||
? formatMoney(summary.totalOutstanding, summary.currency)
|
||||
: "—",
|
||||
hint: "unbilled + billed",
|
||||
icon: HandCoins,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Unbilled",
|
||||
value: summary
|
||||
? formatMoney(summary.unbilledAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.unbilledCount} credits` : undefined,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Billed",
|
||||
value: summary
|
||||
? formatMoney(summary.billedAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.billedCount} on invoices` : undefined,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as ShippingLineCreditStatus | null) ?? null);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{selectedCredits.length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Group
|
||||
px="md"
|
||||
py="sm"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
bg="var(--mantine-color-edr-green-0)"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{selectedCredits.length} credit
|
||||
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
|
||||
{formatMoney(selectedTotal, selectedCredits[0].currency)}
|
||||
{" — "}
|
||||
{selectedCredits[0].shippingLineCompany?.name ?? ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
leftSection={<Receipt size={14} />}
|
||||
onClick={() => setInvoiceOpen(true)}
|
||||
>
|
||||
Generate invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credits match this filter."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load credits.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={invoiceOpen}
|
||||
onClose={() => setInvoiceOpen(false)}
|
||||
title="Generate invoice"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
One invoice for{" "}
|
||||
<Text component="span" fw={600} c="edr-text">
|
||||
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
|
||||
</Text>{" "}
|
||||
billing the selected credits. The line pays it at any CBE channel —
|
||||
there is no payment window.
|
||||
</Text>
|
||||
|
||||
<Stack gap={6}>
|
||||
{selectedCredits.map((credit) => (
|
||||
<Group key={credit.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" truncate>
|
||||
{credit.booking?.reference ?? credit.description ?? credit.id}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMoney(Number(credit.amount), credit.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(
|
||||
selectedTotal,
|
||||
selectedCredits[0]?.currency ?? "ETB",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<NumberInput
|
||||
label="Due in days"
|
||||
description="Optional — defaults to the standard invoice term."
|
||||
placeholder="14"
|
||||
min={1}
|
||||
value={dueInDays}
|
||||
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setInvoiceOpen(false)}
|
||||
disabled={isInvoicing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isInvoicing}
|
||||
onClick={() =>
|
||||
generateInvoice({
|
||||
creditIds: selectedCredits.map((c) => c.id),
|
||||
...(typeof dueInDays === "number"
|
||||
? { dueInDays }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Generate & issue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
|
||||
// Booking window for the schedule being created: off = inherit the live global
|
||||
// rules (the default), on = the values in `windowForm` are frozen onto it.
|
||||
const [configureWindow, setConfigureWindow] = useState(false);
|
||||
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
enabled: Boolean(routeId),
|
||||
}),
|
||||
);
|
||||
// For the create modal's dedication picker. 100 covers every line EDR deals
|
||||
// with; fetched only while the modal is open.
|
||||
const shippingLinesQuery = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
enabled: createOpen,
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const dispatchSchedule = useMutation(
|
||||
api.trainScheduling.dispatchSchedule.mutationOptions(),
|
||||
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
setWindowForm(null);
|
||||
setCreateOpen(false);
|
||||
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
placeholder="None — normal customer train"
|
||||
clearable
|
||||
searchable
|
||||
data={(shippingLinesQuery.data?.items ?? [])
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({ value: line.id, label: line.name }))}
|
||||
value={shippingLineCompanyId || null}
|
||||
onChange={(v) => setShippingLineCompanyId(v ?? "")}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Reverse wagon order"
|
||||
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."
|
||||
|
||||
@@ -44,6 +44,20 @@ import type {
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
RuleEngineListResult,
|
||||
RuleEngineRecord,
|
||||
@@ -156,6 +170,8 @@ import {
|
||||
import { containerTypesService } from "./container-types.service";
|
||||
import { containerService, type Container } from "./containerService";
|
||||
import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
@@ -2792,6 +2808,160 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCompanies: {
|
||||
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
|
||||
"shippingLineCompanies",
|
||||
"list",
|
||||
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
|
||||
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, ShippingLineCompany>(
|
||||
"shippingLineCompanies",
|
||||
"getById",
|
||||
({ id }) => shippingLineCompaniesService.getById(id),
|
||||
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
|
||||
),
|
||||
|
||||
register: endpoint<
|
||||
CreateShippingLineCompanyDto,
|
||||
RegisterShippingLineCompanyResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"register",
|
||||
(dto) => shippingLineCompaniesService.register(dto),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
|
||||
),
|
||||
|
||||
resendActivation: endpoint<
|
||||
{ id: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"resendActivation",
|
||||
({ id, channel }) =>
|
||||
shippingLineCompaniesService.resendActivation(id, channel),
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCredits: {
|
||||
summary: endpoint<{ shippingLineId?: string }, OutstandingTotals>(
|
||||
"shippingLineCredits",
|
||||
"summary",
|
||||
({ shippingLineId }) => shippingLineCreditsService.summary(shippingLineId),
|
||||
({ shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.summary(shippingLineId),
|
||||
),
|
||||
|
||||
list: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedShippingLineCredits
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"list",
|
||||
(filter) => shippingLineCreditsService.list(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.list(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
generateInvoice: endpoint<
|
||||
{ creditIds: string[]; dueInDays?: number },
|
||||
GeneratedCreditInvoice
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"generateInvoice",
|
||||
({ creditIds, dueInDays }) =>
|
||||
shippingLineCreditsService.generateInvoice(creditIds, dueInDays),
|
||||
undefined,
|
||||
// Billing a batch changes ledger rows, the summary totals and (via the
|
||||
// draft invoice) the invoices list.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
|
||||
listInvoices: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedCreditInvoices
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"listInvoices",
|
||||
(filter) => shippingLineCreditsService.listInvoices(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.invoices(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
pendingInvoiceActions: endpoint<
|
||||
{ invoiceIds: string[] },
|
||||
CreditInvoicePendingAction[]
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"pendingInvoiceActions",
|
||||
({ invoiceIds }) =>
|
||||
shippingLineCreditsService.pendingInvoiceActions(invoiceIds),
|
||||
({ invoiceIds }) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"pending-actions",
|
||||
[...invoiceIds].sort().join(","),
|
||||
] as const,
|
||||
),
|
||||
|
||||
requestInvoiceAction: endpoint<
|
||||
{
|
||||
invoiceId: string;
|
||||
action: "MARK_PAID" | "CANCEL";
|
||||
reason: string;
|
||||
paymentReference?: string;
|
||||
},
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"requestInvoiceAction",
|
||||
({ invoiceId, action, reason, paymentReference }) =>
|
||||
shippingLineCreditsService.requestInvoiceAction(
|
||||
invoiceId,
|
||||
action,
|
||||
reason,
|
||||
paymentReference,
|
||||
),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT],
|
||||
),
|
||||
|
||||
decideInvoiceAction: endpoint<
|
||||
{ approvalId: string; approve: boolean; note?: string },
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"decideInvoiceAction",
|
||||
({ approvalId, approve, note }) =>
|
||||
shippingLineCreditsService.decideInvoiceAction(approvalId, approve, note),
|
||||
undefined,
|
||||
// Approving executes a billing action, so both surfaces move.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
},
|
||||
|
||||
customers: {
|
||||
stats: endpoint<Record<string, never>, CompanyStats>(
|
||||
"customers",
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* Rates only: "true" lists shipping-line rates, "false" standard customer
|
||||
* ones. Omitted lists both.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -214,6 +219,7 @@ export const ruleEngineService = {
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
isShippingLineRate: params?.isShippingLineRate,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
export const shippingLineCompaniesService = {
|
||||
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCompanies>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
{ params: { page, limit } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<ShippingLineCompany> {
|
||||
return apiClient
|
||||
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the carrier's account and record, then sends an activation link.
|
||||
* Staff never set or see a password — the line chooses its own from the link.
|
||||
*/
|
||||
register(
|
||||
dto: CreateShippingLineCompanyDto,
|
||||
): Promise<RegisterShippingLineCompanyResult> {
|
||||
return apiClient
|
||||
.post<RegisterShippingLineCompanyResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
dto,
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
resendActivation(
|
||||
id: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
export interface ShippingLineCreditListFilter {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
/** Narrow to one line; omit for all lines. */
|
||||
shippingLineId?: string;
|
||||
}
|
||||
|
||||
export const shippingLineCreditsService = {
|
||||
/** Outstanding totals — every line, or one line when an id is given. */
|
||||
summary(shippingLineId?: string): Promise<OutstandingTotals> {
|
||||
return apiClient
|
||||
.get<OutstandingTotals>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.SUMMARY, {
|
||||
params: shippingLineId ? { shippingLineId } : {},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** The whole credit ledger, newest first, optionally filtered. */
|
||||
list(
|
||||
filter: ShippingLineCreditListFilter = {},
|
||||
): Promise<PaginatedShippingLineCredits> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCredits>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bill a batch of unbilled credits as one invoice. The API enforces that all
|
||||
* credits belong to one shipping line and share one currency.
|
||||
*/
|
||||
generateInvoice(
|
||||
creditIds: string[],
|
||||
dueInDays?: number,
|
||||
): Promise<GeneratedCreditInvoice> {
|
||||
return apiClient
|
||||
.post<GeneratedCreditInvoice>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICE, {
|
||||
creditIds,
|
||||
...(dueInDays ? { dueInDays } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Credit invoices with any pending manual-action request attached. */
|
||||
listInvoices(filter: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
} = {}): Promise<PaginatedCreditInvoices> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedCreditInvoices>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICES, {
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Undecided manual-action requests for a batch of invoice ids. */
|
||||
pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<CreditInvoicePendingAction[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return apiClient
|
||||
.get<CreditInvoicePendingAction[]>(
|
||||
`${URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE}/invoice-actions/pending`,
|
||||
{ params: { invoiceIds: invoiceIds.join(",") } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Maker step: raise a mark-paid or cancel request on a credit invoice. */
|
||||
requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: "MARK_PAID" | "CANCEL",
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url =
|
||||
action === "MARK_PAID"
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.MARK_PAID_REQUEST(invoiceId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.CANCEL_REQUEST(invoiceId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, {
|
||||
reason,
|
||||
...(paymentReference ? { paymentReference } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Decision step: approve (executes) or reject a pending request. */
|
||||
decideInvoiceAction(
|
||||
approvalId: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url = approve
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.APPROVE_ACTION(approvalId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.REJECT_ACTION(approvalId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, note ? { note } : {})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -90,6 +90,9 @@ export interface BookingContainerLine {
|
||||
containerNumber?: string | null;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
/** How many of this line are hazardous / refrigerated — 0 when none. */
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
containerType?: {
|
||||
id: string;
|
||||
code?: string;
|
||||
@@ -199,6 +202,7 @@ export interface BookingDetail {
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
isHazardous: boolean;
|
||||
isReefer?: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
priorityScore: number;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ResetChannel, ResetPasswordResult } from "./customer";
|
||||
|
||||
export type ShippingLineStatus = "active" | "suspended";
|
||||
|
||||
/**
|
||||
* A carrier with its own portal login, registered by staff.
|
||||
*
|
||||
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
|
||||
* that is a pricing lookup — a code/label a booking points at — with no account
|
||||
* and no login. This one is the account.
|
||||
*/
|
||||
export interface ShippingLineCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
imoNumber: string | null;
|
||||
bicCode: string | null;
|
||||
status: ShippingLineStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateShippingLineCompanyDto {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
scacCode?: string;
|
||||
imoNumber?: string;
|
||||
bicCode?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface RegisterShippingLineCompanyResult {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/**
|
||||
* Masked destination the activation link went to, or null when delivery
|
||||
* failed. The registration still succeeded — the link is resendable.
|
||||
*/
|
||||
activationSentTo: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCompanies {
|
||||
items: ShippingLineCompany[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type { ResetChannel, ResetPasswordResult };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
* Mirrors `shipping-line-credits` API responses.
|
||||
*/
|
||||
|
||||
export type ShippingLineCreditStatus =
|
||||
| "UNBILLED"
|
||||
| "BILLED"
|
||||
| "PAID"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface ShippingLineCredit {
|
||||
id: string;
|
||||
shippingLineCompanyId: string;
|
||||
bookingId: string;
|
||||
/** Numeric column — serialized as a string by the API. */
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: ShippingLineCreditStatus;
|
||||
description: string | null;
|
||||
invoiceId: string | null;
|
||||
billedAt: string | null;
|
||||
paidAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
cancellationReason: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string } | null;
|
||||
invoice?: { id: string; invoiceNumber: string } | null;
|
||||
shippingLineCompany?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
unbilledAmount: number;
|
||||
billedAmount: number;
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCredits {
|
||||
items: ShippingLineCredit[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** The invoice minted from a batch of unbilled credits (subset of fields). */
|
||||
export interface GeneratedCreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
totalAmount: string | number;
|
||||
currency: string;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
export type CreditInvoiceActionType = "MARK_PAID" | "CANCEL";
|
||||
export type CreditInvoiceActionStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
/** An undecided manual-action request attached to a credit invoice. */
|
||||
export interface CreditInvoicePendingAction {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
action: CreditInvoiceActionType;
|
||||
status: CreditInvoiceActionStatus;
|
||||
requestedBy: string;
|
||||
reason: string;
|
||||
paymentReference: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A credit invoice row in the staff list, enriched by the API. */
|
||||
export interface CreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
shippingLineCompanyId: string | null;
|
||||
shippingLineName: string | null;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
export interface PaginatedCreditInvoices {
|
||||
items: CreditInvoice[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -960,6 +960,11 @@ export interface CreateTrainSchedulePayload {
|
||||
maxWagonsPerTrain?: number;
|
||||
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
||||
reverseWagonOrder?: boolean;
|
||||
/**
|
||||
* Dedicate this departure to one shipping line — hidden from customers,
|
||||
* visible only to that line in its portal. Omit for a normal customer train.
|
||||
*/
|
||||
shippingLineCompanyId?: string;
|
||||
/**
|
||||
* Configure the booking window for THIS schedule instead of inheriting the
|
||||
* live global rules. Omit to follow the global rules (the default).
|
||||
|
||||
@@ -59,6 +59,15 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import {
|
||||
ShippingLineBookingDetailPage,
|
||||
ShippingLineBookingsPage,
|
||||
ShippingLineCompletePage,
|
||||
ShippingLineHelpPage,
|
||||
ShippingLineHomePage,
|
||||
ShippingLineInvoicesPage,
|
||||
ShippingLineSettingsPage,
|
||||
} from "./pages/shipping-line";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -129,12 +138,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* can be dismissed to use those pages. Visiting any other page bounces back to
|
||||
* home and re-opens the wizard. New users (no company yet) are treated the same
|
||||
* as users who haven't completed onboarding.
|
||||
*
|
||||
* Shipping lines are exempt: staff register them with their details already
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
// Keyed off a positive shipping-line identification, never off "no company":
|
||||
// that is also true mid-fetch and on error, which would let customers slip
|
||||
// past onboarding whenever the request failed.
|
||||
const needsOnboarding = isShippingLine
|
||||
? false
|
||||
: !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
@@ -176,21 +193,69 @@ function OnboardingGate() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-only routes. A shipping line that lands on one (an old link, a
|
||||
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
|
||||
* contract/company-shaped page that has no meaning for it.
|
||||
*/
|
||||
function RequireCustomer() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||
// throw the user into the wrong app.
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
|
||||
function RequireShippingLine() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (!isShippingLine) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||
* Waits for the company query: `isShippingLine` is false while that request is
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
function RedirectIfAuthed() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Landing page for visitors; authenticated users go straight to the portal. */
|
||||
function LandingRoute() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <EDRFreightLandingPage />;
|
||||
}
|
||||
|
||||
@@ -230,6 +295,37 @@ const sidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
|
||||
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
|
||||
* Bookings / Invoices pages are different pages at different routes.
|
||||
*/
|
||||
const shippingLineSidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/shipping-line/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
href: "/shipping-line/invoices",
|
||||
icon: <Receipt size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Settings",
|
||||
href: "/shipping-line/settings",
|
||||
icon: <Settings size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Help & Support",
|
||||
href: "/shipping-line/help",
|
||||
icon: <LifeBuoy size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -310,81 +406,152 @@ const App = () => {
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
{/* Shipping-line app. Its own layout and sidebar, and its own pages
|
||||
at their own routes — nothing here is shared with the customer
|
||||
branch below beyond the shell component itself. Contracts are
|
||||
absent by design: shipping lines request bookings directly. */}
|
||||
<Route element={<RequireShippingLine />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={shippingLineSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
// Support chat is company-scoped; a shipping line has no
|
||||
// company, so every poll would 403.
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/shipping-line"
|
||||
element={<ShippingLineHomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id"
|
||||
element={<ShippingLineBookingDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id/complete"
|
||||
element={<ShippingLineCompletePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
/>
|
||||
{/* Same detail component as the customer's /billing/:id — the
|
||||
API scopes my-invoices to the signed-in payer either way,
|
||||
and the page derives its back target from the URL. */}
|
||||
<Route
|
||||
path="/shipping-line/invoices/:id"
|
||||
element={<InvoiceDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/help"
|
||||
element={<ShippingLineHelpPage />}
|
||||
/>
|
||||
{/* Old shared links land on the shipping-line equivalents. */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Navigate to="/shipping-line/settings" replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
browsable here. New-booking entry still routes via a contract. */}
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/edit"
|
||||
element={<EditBookingPage />}
|
||||
/>
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
clearance — same form, submits to the complete endpoint. */}
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface AppLayoutProps {
|
||||
}[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||
companyType?: string | null;
|
||||
/**
|
||||
* Render the floating support-chat launcher. Defaults to true so the customer
|
||||
* portal is unaffected; shipping lines pass false — support chat is scoped to
|
||||
* a company, which they do not have.
|
||||
*/
|
||||
showSupportWidget?: boolean;
|
||||
/** Create a new service profile of the given type (with business license). */
|
||||
onCreateProfile?: (
|
||||
type: ServiceType,
|
||||
@@ -107,23 +113,20 @@ function getActivePage(
|
||||
activePath: string,
|
||||
): { label: string } | null {
|
||||
const path = activePath.toLowerCase();
|
||||
for (const item of items) {
|
||||
if (
|
||||
path === item.href.toLowerCase() ||
|
||||
path.startsWith(item.href.toLowerCase() + "/")
|
||||
) {
|
||||
return { label: item.label };
|
||||
}
|
||||
if (item.children) {
|
||||
const childMatch = item.children.find(
|
||||
(c) =>
|
||||
path === c.href.toLowerCase() ||
|
||||
path.startsWith(c.href.toLowerCase() + "/"),
|
||||
);
|
||||
if (childMatch) return { label: childMatch.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
// Longest match wins, for the same reason as the sidebar's isItemActive:
|
||||
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
|
||||
// parent, which a first-match-wins scan would report as "Home".
|
||||
const best = items
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.filter(
|
||||
(item) =>
|
||||
path === item.href.toLowerCase() ||
|
||||
path.startsWith(item.href.toLowerCase() + "/"),
|
||||
)
|
||||
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||
|
||||
return best ? { label: best.label } : null;
|
||||
}
|
||||
|
||||
const navClassNames = (active: boolean) => {
|
||||
@@ -155,6 +158,7 @@ export function AppLayout({
|
||||
companyType,
|
||||
onCreateProfile,
|
||||
onReapplyProfile,
|
||||
showSupportWidget = true,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -262,9 +266,19 @@ export function AppLayout({
|
||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||
|
||||
// Longest matching href wins. A plain prefix test would light up every
|
||||
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
|
||||
// bookings", Home would stay highlighted on every page beneath it. Exact
|
||||
// matches still win outright, so customer routes are unaffected — their
|
||||
// sidebar hrefs are siblings, never nested inside one another.
|
||||
const bestMatchHref = sidebarItems
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.map((item) => item.href.toLowerCase())
|
||||
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
bestMatchHref === item.href.toLowerCase();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -371,7 +385,9 @@ export function AppLayout({
|
||||
onClick={() =>
|
||||
openServiceModal(p.type as ServiceType, p)
|
||||
}
|
||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||
leftSection={
|
||||
<RefreshCw size={15} strokeWidth={1.8} />
|
||||
}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
@@ -850,8 +866,10 @@ export function AppLayout({
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Floating customer-support chat launcher. */}
|
||||
<SupportWidget />
|
||||
{/* Floating customer-support chat launcher. Hidden when the caller opts
|
||||
out: support chat resolves the user's external profile → company, and
|
||||
a shipping line has neither, so every poll would 403. */}
|
||||
{showSupportWidget && <SupportWidget />}
|
||||
|
||||
{/* Create-profile modal — opens when switching to a mode the company
|
||||
doesn't have a profile for yet. */}
|
||||
@@ -893,7 +911,9 @@ export function AppLayout({
|
||||
</Alert>
|
||||
)}
|
||||
<FileInput
|
||||
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||
label={
|
||||
reapplyId ? "Business license (optional)" : "Business license"
|
||||
}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
|
||||
@@ -162,7 +162,15 @@ export default function OnboardingResumeBanner({
|
||||
* Self-hides when there's nothing outstanding.
|
||||
*/
|
||||
export function AccountReviewBanner() {
|
||||
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
|
||||
const { company, companyStatus, reviewStatus, reviewNote, isShippingLine } =
|
||||
useAuth();
|
||||
|
||||
// Shipping lines have no company approval, no operational profiles and no
|
||||
// profile-edit review — every branch below is about customer state they do
|
||||
// not have. Bail explicitly rather than relying on each check happening to
|
||||
// fall through.
|
||||
if (isShippingLine) return null;
|
||||
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const pending = profiles.filter((p) => p.status === "pending");
|
||||
const approved = profiles.filter((p) => p.status === "active");
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type {
|
||||
CompanyInfoResponse,
|
||||
ProfileTypeValue,
|
||||
} from "@/services/companies.service";
|
||||
import {
|
||||
companiesService,
|
||||
isShippingLineAccount,
|
||||
} from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -158,7 +164,21 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
|
||||
/**
|
||||
* Shipping lines share the portal with customers but have no company, no
|
||||
* external profile and no onboarding. Identified positively from the
|
||||
* backend's discriminator — never from "company is missing", which is also
|
||||
* true while the fetch is in flight or after it fails.
|
||||
*/
|
||||
const isShippingLine = isShippingLineAccount(accountInfo);
|
||||
const shippingLine = isShippingLine ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line.
|
||||
const companyInfo = isShippingLine
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -259,8 +279,11 @@ const useAuth = () => {
|
||||
isPending,
|
||||
isAuthenticated,
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
// The whole getInfo payload, not the `company` field within it (historical
|
||||
// name). Null for a shipping line: consumers read `.company` / `.profile`
|
||||
// off it, and a shipping line has neither.
|
||||
company: companyInfo,
|
||||
customer: companyInfo,
|
||||
canBook,
|
||||
hasActiveProfile,
|
||||
hasPendingProfile,
|
||||
@@ -272,6 +295,8 @@ const useAuth = () => {
|
||||
isUnderReview,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
isShippingLine,
|
||||
shippingLine,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
@@ -19,6 +19,10 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordWasReset =
|
||||
(location.state as { passwordReset?: boolean } | null)?.passwordReset ===
|
||||
true;
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -56,6 +60,21 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
{/*
|
||||
Set by the reset flows on their way here. Without it the account
|
||||
holder lands on a bare sign-in form with no sign the reset worked —
|
||||
and the link is single-use, so there is no way back to check.
|
||||
*/}
|
||||
{passwordWasReset ? (
|
||||
<Alert
|
||||
color="green"
|
||||
variant="light"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
>
|
||||
Your password has been updated. Sign in with your new password.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
||||
import { AlertCircle, KeyRound } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, KeyRound, LogIn } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
@@ -33,6 +33,7 @@ export default function ResetPasswordLinkPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [succeeded, setSucceeded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !token) {
|
||||
@@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() {
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login", { replace: true, state: { passwordReset: true } });
|
||||
// Confirm in place rather than bouncing to /login: the old redirect
|
||||
// passed `passwordReset: true` in route state that no page ever read, so
|
||||
// the account holder landed on a bare sign-in form with no sign the reset
|
||||
// had worked — and the link is single-use, so there is no way back to
|
||||
// check.
|
||||
setSucceeded(true);
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Password set — the link is now spent, so this is the last thing the account
|
||||
// holder sees. It replaces the whole form rather than sitting above it.
|
||||
if (succeeded) {
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Password updated"
|
||||
taglineBody="Your EDR Freight account is ready to use."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||
<CheckCircle2 size={30} strokeWidth={2.2} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 mt-4 space-y-2 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
You're all set
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Your password has been updated
|
||||
{account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}.
|
||||
Sign in with your new password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<LogIn size={18} />}
|
||||
onClick={() =>
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
})
|
||||
}
|
||||
>
|
||||
Go to sign in
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-gray-400">
|
||||
For your security, this reset link has now been used and will not
|
||||
work again.
|
||||
</p>
|
||||
</Stack>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Set a new password"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
@@ -59,6 +59,11 @@ function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
// Mounted at /billing/:id (customer) and /shipping-line/invoices/:id — the
|
||||
// back target follows whichever list the reader came through.
|
||||
const backHref = useLocation().pathname.startsWith("/shipping-line")
|
||||
? "/shipping-line/invoices"
|
||||
: "/billing";
|
||||
const {
|
||||
data: invoice,
|
||||
isLoading,
|
||||
@@ -87,7 +92,7 @@ export default function InvoiceDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/billing")}
|
||||
onClick={() => navigate(backHref)}
|
||||
mb="md"
|
||||
>
|
||||
Back to invoices
|
||||
@@ -165,7 +170,7 @@ export default function InvoiceDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/billing")}
|
||||
onClick={() => navigate(backHref)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
Container,
|
||||
FileText,
|
||||
Flame,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Snowflake,
|
||||
Train,
|
||||
Upload,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||
import {
|
||||
BodyGrid,
|
||||
CardTitle,
|
||||
PageShell,
|
||||
SectionCard,
|
||||
} from "@/pages/bookings/BookingDetailPage/components/layout";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
import {
|
||||
bookingDocState,
|
||||
hasDocuments,
|
||||
needsUpload,
|
||||
DOC_STATE_ACTION_LABEL,
|
||||
DOC_STATE_COLOR,
|
||||
DOC_STATE_LABEL,
|
||||
} from "./booking-doc-state";
|
||||
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||
|
||||
/**
|
||||
* Statuses a booking can be cancelled from — everything before it is priced.
|
||||
* Mirrors SHIPPING_LINE_CANCELLABLE_STATUSES on the API.
|
||||
*/
|
||||
const CANCELLABLE_STATUSES = new Set([
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shipping-line booking detail.
|
||||
*
|
||||
* Shaped like the customer booking detail page — same shell, same two-column
|
||||
* body — but split into Documents / Booking details tabs, since a bare
|
||||
* shipping-line booking has little else to show until it is completed.
|
||||
*/
|
||||
export default function ShippingLineBookingDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [docsOpen, setDocsOpen] = useState(false);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
const bookingQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings", id],
|
||||
queryFn: () => shippingLineBookingsService.getById(id),
|
||||
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,
|
||||
});
|
||||
|
||||
// Operations view: the assigned/requested train and the wagons the batch
|
||||
// engine allocated — feeds the Wagons & Train tab.
|
||||
const operationsQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings", id, "operations"],
|
||||
queryFn: () => shippingLineBookingsService.operations(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
|
||||
onSuccess: () => {
|
||||
setCancelOpen(false);
|
||||
setCancelReason("");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingQuery.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Center py={80}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookingQuery.isError || !bookingQuery.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert color="red">This booking could not be loaded.</Alert>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const wantsUpload = needsUpload(docState);
|
||||
const actionNeeded = docState === "ACTION_NEEDED";
|
||||
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
// Booking flag OR any line count — per-line opt-ins must light the badge
|
||||
// even when the booking-level flag lags.
|
||||
const isHazardous =
|
||||
Boolean(booking.isHazardous) ||
|
||||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
const isReefer = containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
|
||||
const breakdown = booking.pricingBreakdown ?? null;
|
||||
const operations = operationsQuery.data ?? null;
|
||||
|
||||
// Mirrors the server's rule: cancellable only before the booking is priced.
|
||||
// Kept in sync deliberately — a button the API would reject is worse than no
|
||||
// button at all.
|
||||
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
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
w="fit-content"
|
||||
leftSection={<ArrowLeft size={15} />}
|
||||
onClick={() => navigate("/shipping-line/bookings")}
|
||||
>
|
||||
Back to bookings
|
||||
</Button>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={8} miw={0}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
<StatusBadge status={status} />
|
||||
{showDocs && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={DOC_STATE_COLOR[docState]}
|
||||
leftSection={
|
||||
actionNeeded ? <AlertCircle size={12} /> : undefined
|
||||
}
|
||||
>
|
||||
{DOC_STATE_LABEL[docState]}
|
||||
</Badge>
|
||||
)}
|
||||
{isHazardous && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
color="red"
|
||||
leftSection={<Flame size={11} />}
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
color="blue"
|
||||
leftSection={<Snowflake size={11} />}
|
||||
>
|
||||
Refrigerated
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={16} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
)}
|
||||
{showDocs && (
|
||||
<Button
|
||||
color={actionNeeded ? "red" : "edr-green"}
|
||||
variant={wantsUpload ? "filled" : "light"}
|
||||
radius="md"
|
||||
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={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}/complete`)
|
||||
}
|
||||
>
|
||||
{status === "OPERATION_CHANGES_REQUESTED"
|
||||
? "Resubmit booking"
|
||||
: "Book"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Operations' return note belongs at the top of the page — the line
|
||||
must see what to fix without hunting through the tabs. */}
|
||||
{status === "OPERATION_CHANGES_REQUESTED" && (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title="Operations requested changes"
|
||||
>
|
||||
{booking.operationChangeNote?.trim() ||
|
||||
"Operations returned your booking request for changes. Resubmit it with an updated shipment day or cargo."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="documents" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={15} />}
|
||||
// The one place a query surfaces without opening the tab.
|
||||
rightSection={
|
||||
actionNeeded ? (
|
||||
<Badge size="xs" circle variant="filled" color="red">
|
||||
!
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="details" leftSection={<Package size={15} />}>
|
||||
Booking details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="cargo" leftSection={<Container size={15} />}>
|
||||
Cargo
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="price" leftSection={<Wallet size={15} />}>
|
||||
Price
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="operations" leftSection={<Train size={15} />}>
|
||||
Wagons & Train
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<SectionCard>
|
||||
<CardTitle>Documents</CardTitle>
|
||||
|
||||
<Stack gap="md" mt="sm">
|
||||
{docState === "ACTION_NEEDED" ? (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={18} />}>
|
||||
Operations returned one or more documents with a query. Open
|
||||
the documents, read the note on each flagged item and upload a
|
||||
corrected file — the booking cannot move on until you do.
|
||||
</Alert>
|
||||
) : docState === "AWAITING" ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<ClipboardList size={18} />}
|
||||
>
|
||||
This booking is waiting on your documents. Upload them for
|
||||
Operations to review — the booking can be completed once they
|
||||
are approved.
|
||||
</Alert>
|
||||
) : docState === "IN_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||
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} />}>
|
||||
{booking.operationChangeNote?.trim() ||
|
||||
"Operations returned your booking request for changes. Resubmit it with an updated shipment day or cargo."}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
>
|
||||
Your documents are approved.
|
||||
{status === "CLEARANCE_READY" &&
|
||||
" Complete the booking with your cargo and shipment day to proceed."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}/complete`)
|
||||
}
|
||||
>
|
||||
{status === "OPERATION_CHANGES_REQUESTED"
|
||||
? "Resubmit booking"
|
||||
: "Book"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="details" pt="lg">
|
||||
<BodyGrid
|
||||
left={
|
||||
<SectionCard>
|
||||
<CardTitle>Shipment</CardTitle>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{/* Set at initiate time from the chosen route, so it is
|
||||
known before Operations reviews the documents. */}
|
||||
<DetailRow
|
||||
label="Route"
|
||||
value={
|
||||
booking.originYard || booking.destinationYard
|
||||
? `${
|
||||
booking.originYard?.label ??
|
||||
booking.originYard?.code ??
|
||||
"—"
|
||||
} → ${
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
"—"
|
||||
}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Freight type"
|
||||
value={booking.freightType ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Trade direction"
|
||||
value={booking.tradeDirection ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Shipment day"
|
||||
value={
|
||||
booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toLocaleDateString()
|
||||
: "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>
|
||||
}
|
||||
right={
|
||||
<SectionCard>
|
||||
<CardTitle>Booking</CardTitle>
|
||||
<Stack gap="xs" mt="sm">
|
||||
<DetailRow label="Reference" value={booking.reference} />
|
||||
<DetailRow label="Status" value={status} />
|
||||
<DetailRow
|
||||
label="Created"
|
||||
value={
|
||||
booking.createdAt
|
||||
? new Date(booking.createdAt).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{/* 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()}
|
||||
/>
|
||||
)}
|
||||
<DetailRow
|
||||
label="Billing currency"
|
||||
value={booking.paymentCurrency ?? "ETB"}
|
||||
/>
|
||||
{booking.cargoFreeText && (
|
||||
<DetailRow
|
||||
label="Cargo description"
|
||||
value={booking.cargoFreeText}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="cargo" pt="lg">
|
||||
<SectionCard>
|
||||
<CardTitle>Cargo</CardTitle>
|
||||
{containers.length === 0 &&
|
||||
!(Number(booking.cargoTotalWeightVgm ?? 0) > 0) ? (
|
||||
<Text fz={13} c="edr-muted" mt="sm">
|
||||
No cargo entered yet — it is added when you complete the
|
||||
booking after your documents are approved.
|
||||
</Text>
|
||||
) : booking.freightType === "BULK" ? (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<DetailRow
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.cargoTypeName ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Total weight"
|
||||
value={`${Number(
|
||||
booking.bulkTotalWeightTons ??
|
||||
booking.cargoTotalWeightVgm ??
|
||||
0,
|
||||
).toLocaleString()} tons`}
|
||||
/>
|
||||
{booking.cargoFreeText && (
|
||||
<DetailRow
|
||||
label="Description"
|
||||
value={booking.cargoFreeText}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="md" mt="sm">
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container type</Table.Th>
|
||||
<Table.Th ta="right">Qty</Table.Th>
|
||||
<Table.Th ta="right">VGM / unit</Table.Th>
|
||||
<Table.Th ta="right">Hazardous</Table.Th>
|
||||
<Table.Th ta="right">Reefer</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz={13}>
|
||||
{line.containerType?.label ??
|
||||
line.containerType?.code ??
|
||||
line.containerSize ??
|
||||
"—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{Number(line.vgmPerUnitTons ?? 0)} t
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{Number(line.hazardousQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="red" fz={13}>
|
||||
{line.hazardousQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{Number(line.reeferQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="blue" fz={13}>
|
||||
{line.reeferQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Per-container manifest, when the numbers were entered. */}
|
||||
{containers.some((l) => (l.units?.length ?? 0) > 0) && (
|
||||
<>
|
||||
<CardTitle>Containers</CardTitle>
|
||||
<Table verticalSpacing="xs" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container no.</Table.Th>
|
||||
<Table.Th>Seal</Table.Th>
|
||||
<Table.Th ta="right">VGM</Table.Th>
|
||||
<Table.Th>Handling</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.flatMap((line) =>
|
||||
(line.units ?? []).map((unit) => (
|
||||
<Table.Tr key={unit.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz={13}>
|
||||
{unit.containerNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{unit.sealNumber ?? "—"}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{Number(unit.vgmTons ?? 0)} t
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{unit.isHazardous && (
|
||||
<Badge size="xs" color="red" variant="filled">
|
||||
Hazardous
|
||||
</Badge>
|
||||
)}
|
||||
{unit.isReefer && (
|
||||
<Badge size="xs" color="blue" variant="filled">
|
||||
Reefer
|
||||
</Badge>
|
||||
)}
|
||||
{!unit.isHazardous && !unit.isReefer && "—"}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)),
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="price" pt="lg">
|
||||
<SectionCard>
|
||||
<CardTitle>Price</CardTitle>
|
||||
{!breakdown?.lineItems?.length ? (
|
||||
<Text fz={13} c="edr-muted" mt="sm">
|
||||
No price yet — the booking is priced when you complete it.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md" mt="sm">
|
||||
<Table verticalSpacing="xs" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Charge</Table.Th>
|
||||
<Table.Th ta="right">Qty</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{breakdown.lineItems.map((item, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Text fz={13}>{item.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} c="edr-muted">
|
||||
{item.quantity ?? 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} fw={600}>
|
||||
{Number(item.amount).toLocaleString()}{" "}
|
||||
{item.currency}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-teal-0)",
|
||||
}}
|
||||
>
|
||||
<Text fw={700}>Total (on credit)</Text>
|
||||
<Text fw={800} fz={18}>
|
||||
{Number(breakdown.totalAmount).toLocaleString()}{" "}
|
||||
{breakdown.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
Charged to your credit account — EDR bills accumulated
|
||||
charges periodically. Quoted{" "}
|
||||
{new Date(breakdown.generatedAt).toLocaleString()}.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="operations" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<SectionCard>
|
||||
<CardTitle>Train</CardTitle>
|
||||
{operationsQuery.isLoading ? (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : !operations?.train ? (
|
||||
<Text fz={13} c="edr-muted" mt="sm">
|
||||
No train yet — Operations assigns one after your booking is
|
||||
reviewed and batched.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Group gap={8}>
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={operations.train.assigned ? "filled" : "light"}
|
||||
color={operations.train.assigned ? "teal" : "yellow"}
|
||||
>
|
||||
{operations.train.assigned ? "Assigned" : "Requested"}
|
||||
</Badge>
|
||||
<StatusBadge status={operations.train.status} />
|
||||
</Group>
|
||||
<DetailRow
|
||||
label="Train"
|
||||
value={
|
||||
operations.train.trainNumber ??
|
||||
operations.train.reference ??
|
||||
"—"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Route"
|
||||
value={`${operations.train.originLabel} → ${operations.train.destinationLabel}`}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Departure"
|
||||
value={new Date(
|
||||
operations.train.scheduledDepartureDate,
|
||||
).toLocaleString()}
|
||||
/>
|
||||
{operations.train.scheduledArrivalDate && (
|
||||
<DetailRow
|
||||
label="Arrival"
|
||||
value={new Date(
|
||||
operations.train.scheduledArrivalDate,
|
||||
).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<CardTitle>Wagons</CardTitle>
|
||||
{operationsQuery.isLoading ? (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : !operations?.wagons?.length ? (
|
||||
<Text fz={13} c="edr-muted" mt="sm">
|
||||
No wagons allocated yet — allocation happens once Operations
|
||||
accepts your booking and builds the train.
|
||||
</Text>
|
||||
) : (
|
||||
<Table
|
||||
verticalSpacing="sm"
|
||||
horizontalSpacing="md"
|
||||
mt="sm"
|
||||
highlightOnHover
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th ta="right">Loaded / capacity</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{operations.wagons.map((wagon) => (
|
||||
<Table.Tr key={wagon.id}>
|
||||
<Table.Td>{wagon.sequenceNo ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz={13}>
|
||||
{wagon.wagonNumber ?? "To be assigned"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{wagon.wagonType ?? "—"}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{Number(wagon.allocatedWeightTons).toLocaleString()}{" "}
|
||||
/ {Number(wagon.capacityTons).toLocaleString()} t
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{wagon.containerNumbers.length
|
||||
? wagon.containerNumbers.join(", ")
|
||||
: "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" radius="sm" variant="light">
|
||||
{wagon.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ShippingLineDocumentsModal
|
||||
booking={docsOpen ? booking : null}
|
||||
onClose={() => setDocsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Cancelling is irreversible, so it asks first rather than firing on the
|
||||
button press. The reason is optional but recorded. */}
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Cancel this booking?
|
||||
</Text>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
Booking <strong>{booking.reference}</strong> will be cancelled. This
|
||||
cannot be undone — you would need to initiate a new booking.
|
||||
</Alert>
|
||||
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
placeholder="Why are you cancelling this booking?"
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
maxLength={500}
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{cancelMutation.isError && (
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
{(cancelMutation.error as Error)?.message ??
|
||||
"Could not cancel the booking."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setCancelOpen(false)}
|
||||
>
|
||||
Keep booking
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancelMutation.isPending}
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="nowrap">
|
||||
<Text fz={13} c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={13} fw={600} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
AlertCircle,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
import {
|
||||
bookingDocState,
|
||||
hasDocuments,
|
||||
needsUpload,
|
||||
DOC_STATE_ACTION_LABEL,
|
||||
DOC_STATE_COLOR,
|
||||
DOC_STATE_LABEL,
|
||||
} from "./booking-doc-state";
|
||||
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
||||
|
||||
/** Statuses where the booking is approved and waiting to be booked. */
|
||||
const BOOKABLE_STATUSES = new Set([
|
||||
"CLEARANCE_READY",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Text fz={12} fw={700} c="edr-muted" tt="uppercase" lts="0.04em">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipping-line bookings list.
|
||||
*
|
||||
* Mirrors the customer bookings list (same DataTable, status badge, row-click
|
||||
* to detail and per-row action menu) so the two portals read the same. What
|
||||
* differs is the flow behind it: contracts do not apply to shipping lines, so
|
||||
* "Initiate booking" creates a bare booking directly rather than routing
|
||||
* through a contract.
|
||||
*/
|
||||
export default function ShippingLineBookingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination();
|
||||
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
queryFn: shippingLineBookingsService.list,
|
||||
});
|
||||
|
||||
const [initiateOpen, setInitiateOpen] = useState(false);
|
||||
|
||||
const rows = useMemo(() => bookingsQuery.data ?? [], [bookingsQuery.data]);
|
||||
|
||||
const status = bookingsQuery.isLoading
|
||||
? "loading"
|
||||
: bookingsQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
const showEmpty = status === "success" && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<ShippingLineBooking>[] = [
|
||||
{
|
||||
id: "reference",
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => (
|
||||
<Text fw={700} fz={14}>
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <ColHeader label="Route" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code;
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code;
|
||||
return (
|
||||
<Text fz={13} c={origin && dest ? undefined : "edr-muted"}>
|
||||
{origin && dest ? `${origin} → ${dest}` : "—"}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <ColHeader label="Status" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
header: () => <ColHeader label="Documents" />,
|
||||
cell: ({ row }) => {
|
||||
const state = bookingDocState(row.original);
|
||||
if (state === "NONE") return null;
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={DOC_STATE_COLOR[state]}
|
||||
leftSection={
|
||||
state === "ACTION_NEEDED" ? <AlertCircle size={12} /> : undefined
|
||||
}
|
||||
>
|
||||
{DOC_STATE_LABEL[state]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: () => <ColHeader label="Created" />,
|
||||
cell: ({ row }) => (
|
||||
<Text fz={13} c="edr-muted">
|
||||
{row.original.createdAt
|
||||
? new Date(row.original.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 200,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const state = bookingDocState(booking);
|
||||
const showDocs = hasDocuments(state);
|
||||
const wantsUpload = needsUpload(state);
|
||||
const bookable = BOOKABLE_STATUSES.has(booking.status as string);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Approved documents make booking the primary move — the docs
|
||||
button steps back into the menu so one action owns the row. */}
|
||||
{bookable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
onClick={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}/complete`)
|
||||
}
|
||||
>
|
||||
{booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||
? "Resubmit"
|
||||
: "Book"}
|
||||
</Button>
|
||||
)}
|
||||
{showDocs && !bookable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
// A queried document is the one case that needs to pull the
|
||||
// eye — it is the only state where the shipping line is
|
||||
// blocking its own booking.
|
||||
color={state === "ACTION_NEEDED" ? "red" : "edr-green"}
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={
|
||||
wantsUpload ? <Upload size={14} /> : <FileText size={14} />
|
||||
}
|
||||
onClick={() => setDocsBooking(booking)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[state]}
|
||||
</Button>
|
||||
)}
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}`)
|
||||
}
|
||||
>
|
||||
View details
|
||||
</Menu.Item>
|
||||
{showDocs && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
wantsUpload ? (
|
||||
<Upload size={15} />
|
||||
) : (
|
||||
<FileText size={15} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDocsBooking(booking)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[state]}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{bookable && (
|
||||
<Menu.Item
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}/complete`)
|
||||
}
|
||||
>
|
||||
{booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||
? "Resubmit booking"
|
||||
: "Book"}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Request a booking directly — no contract required.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setInitiateOpen(true)}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card radius={16} p={0} withBorder style={{ borderColor: "#E6ECF2" }}>
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap="xs" py={64}>
|
||||
<Package size={28} className="text-slate-300" />
|
||||
<Text c="edr-muted" size="sm">
|
||||
No bookings yet — initiate one to get started.
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
mt="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => setInitiateOpen(true)}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={status}
|
||||
onRowClick={(row) =>
|
||||
navigate(
|
||||
`/shipping-line/bookings/${(row as ShippingLineBooking).id}`,
|
||||
)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: Math.max(
|
||||
1,
|
||||
Math.ceil(rows.length / pagination.pageSize),
|
||||
),
|
||||
totalCount: rows.length,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShippingLineInitiateModal
|
||||
opened={initiateOpen}
|
||||
onClose={() => setInitiateOpen(false)}
|
||||
onCreated={(booking) => {
|
||||
setInitiateOpen(false);
|
||||
// Straight into the new booking — documents are the next thing owed.
|
||||
navigate(`/shipping-line/bookings/${booking.id}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShippingLineDocumentsModal
|
||||
booking={docsBooking}
|
||||
onClose={() => setDocsBooking(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, Clock, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
/**
|
||||
* Document upload for a shipping-line booking.
|
||||
*
|
||||
* Deliberately the same surface as the customer clearance modal
|
||||
* (`BookingActionModal`): the grid is built from the booking's clearance view,
|
||||
* so every field shows its uploaded file, its review badge and the reviewer's
|
||||
* note, and can be replaced in place — an approved document is locked, exactly
|
||||
* as it is for customers.
|
||||
*/
|
||||
export default function ShippingLineDocumentsModal({
|
||||
booking,
|
||||
onClose,
|
||||
}: {
|
||||
booking: ShippingLineBooking | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { view, viewer } = useFileViewer();
|
||||
// Files picked but not yet submitted, keyed by document field.
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
|
||||
// Each booking gets a fresh sheet — otherwise files staged for one booking
|
||||
// would still be attached when the modal reopens on another.
|
||||
useEffect(() => {
|
||||
setPending({});
|
||||
}, [booking?.id]);
|
||||
|
||||
const clearanceQuery = useQuery({
|
||||
queryKey: ["shipping-line-clearance", booking?.id],
|
||||
queryFn: () => shippingLineBookingsService.getClearance(booking!.id),
|
||||
enabled: booking !== null,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
shippingLineBookingsService.uploadDocuments(booking!.id, pending),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-clearance", booking?.id],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const clearance = clearanceQuery.data;
|
||||
const status = clearance?.status ?? booking?.status ?? "";
|
||||
|
||||
// The fields this booking asks for. `uploadedBy: "gl"` rows are staff output
|
||||
// documents, which the uploader never fills in.
|
||||
const docs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "gl"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Uploads are accepted while the booking is awaiting documents or already in
|
||||
// review (fixing a queried one) — matching the server's own status gate.
|
||||
const canUpload =
|
||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
// Documents a reviewer sent back. These are what the shipping line has to
|
||||
// act on, and the reason the modal leads with a red banner rather than the
|
||||
// neutral "in review" one.
|
||||
const queriedDocs = docs.filter((d) => d.reviewStatus === "QUERIED");
|
||||
|
||||
const missingRequired = docs.filter(
|
||||
(d) => d.required && !d.file && !pending[d.fileKey],
|
||||
);
|
||||
const hasStaged = Object.keys(pending).length > 0;
|
||||
|
||||
// First submission must cover every required field; later rounds only need
|
||||
// the specific documents being corrected.
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStaged && missingRequired.length === 0
|
||||
: hasStaged;
|
||||
|
||||
const stage = (fileKey: string, file: File | null) =>
|
||||
setPending((p) => {
|
||||
if (file) return { ...p, [fileKey]: file };
|
||||
const next = { ...p };
|
||||
delete next[fileKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={booking !== null}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="xl"
|
||||
radius="md"
|
||||
title={
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Booking documents
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" ff="monospace">
|
||||
{booking?.reference}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
styles={{ body: { paddingTop: 8 } }}
|
||||
>
|
||||
{clearanceQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : docs.length === 0 ? (
|
||||
<Text fz="13px" c="dimmed" py="md">
|
||||
No document requirements are configured yet. Staff set these up in
|
||||
the backoffice under Settings → File settings.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{queriedDocs.length > 0 ? (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
{queriedDocs.length === 1
|
||||
? `"${queriedDocs[0].label}" was returned with a query. `
|
||||
: `${queriedDocs.length} documents were returned with a query. `}
|
||||
Read the note on each flagged document below and upload a
|
||||
corrected file.
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<Clock size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Our team is reviewing your documents. Only re-upload the
|
||||
documents flagged with a query below — approved documents stay
|
||||
as they are.
|
||||
</Alert>
|
||||
) : status === "CLEARANCE_READY" ? (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Your documents are approved.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Upload every required document (marked *) below to start the
|
||||
review.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required:{" "}
|
||||
{missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
{docs.map((doc) => (
|
||||
<ClearanceDocumentUploadCard
|
||||
key={doc.fileKey}
|
||||
label={doc.label}
|
||||
required={doc.required}
|
||||
reviewStatus={doc.reviewStatus ?? undefined}
|
||||
note={doc.note}
|
||||
uploadedFile={doc.file}
|
||||
stagedFile={pending[doc.fileKey] ?? null}
|
||||
canUpload={canUpload}
|
||||
// An approved document is final — same rule as the customer
|
||||
// flow, so it renders read-only with just a preview.
|
||||
onStageFile={
|
||||
canUpload && doc.reviewStatus !== "APPROVED"
|
||||
? (file) => stage(doc.fileKey, file)
|
||||
: undefined
|
||||
}
|
||||
onPreview={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert color="red" mt="md" icon={<AlertCircle size={16} />}>
|
||||
{(uploadMutation.error as Error)?.message ??
|
||||
"Could not upload the documents."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xl" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{canUpload && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
loading={uploadMutation.isPending}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Submit documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line help & support. Separate from the customer `/help` page, which
|
||||
* is public (the auth screens link to it) and carries its own doc chrome; this
|
||||
* one lives inside the shipping-line app layout and will hold guidance written
|
||||
* for shipping lines.
|
||||
*/
|
||||
export default function ShippingLineHelpPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Help & Support"
|
||||
description="Guides and support for shipping lines."
|
||||
icon={<LifeBuoy size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
FileText,
|
||||
FileUp,
|
||||
LifeBuoy,
|
||||
MapPin,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
TrainFront,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||
import { Card, StatKpi } from "@/pages/MyPortalPage/components";
|
||||
import { cv } from "@/pages/MyPortalPage/constants";
|
||||
import {
|
||||
shippingLineBookingsService,
|
||||
type ShippingLineBooking,
|
||||
type ShippingLineTrain,
|
||||
} from "@/services/shipping-line-bookings.service";
|
||||
|
||||
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
||||
|
||||
/* ── Pending-action derivation ─────────────────────────────────────────────
|
||||
* One row per booking that is waiting on the LINE (never on EDR). Priority:
|
||||
* a queried/returned document set outranks everything — it blocks the rest. */
|
||||
|
||||
type ActionKind = "fix" | "resubmit" | "book" | "upload";
|
||||
|
||||
interface PendingAction {
|
||||
kind: ActionKind;
|
||||
booking: ShippingLineBooking;
|
||||
urgent: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ACTION_META: Record<
|
||||
ActionKind,
|
||||
{ icon: typeof FileUp; color: string; button: string }
|
||||
> = {
|
||||
fix: { icon: FileUp, color: "red", button: "Fix documents" },
|
||||
resubmit: { icon: RefreshCw, color: "orange", button: "Resubmit" },
|
||||
book: { icon: PackagePlus, color: "violet", button: "Book" },
|
||||
upload: { icon: Upload, color: "blue", button: "Upload documents" },
|
||||
};
|
||||
|
||||
function deriveActions(bookings: ShippingLineBooking[]): PendingAction[] {
|
||||
const actions: PendingAction[] = [];
|
||||
for (const b of bookings) {
|
||||
const status = b.status as string;
|
||||
if (status === "CANCELLED") continue;
|
||||
if (b.hasQueriedDocuments || status === "CHANGES_REQUESTED") {
|
||||
actions.push({
|
||||
kind: "fix",
|
||||
booking: b,
|
||||
urgent: true,
|
||||
description: "A reviewer queried your documents — fix and re-upload.",
|
||||
});
|
||||
} else if (status === "OPERATION_CHANGES_REQUESTED") {
|
||||
actions.push({
|
||||
kind: "resubmit",
|
||||
booking: b,
|
||||
urgent: true,
|
||||
description:
|
||||
"Operations returned your request — update it and resubmit.",
|
||||
});
|
||||
} else if (status === "CLEARANCE_READY") {
|
||||
actions.push({
|
||||
kind: "book",
|
||||
booking: b,
|
||||
urgent: false,
|
||||
description:
|
||||
"Documents approved — enter the cargo and shipment day to book.",
|
||||
});
|
||||
} else if (status === "AWAITING_DOCUMENTS") {
|
||||
actions.push({
|
||||
kind: "upload",
|
||||
booking: b,
|
||||
urgent: false,
|
||||
description: "Upload your documents to start the review.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions.sort((a, b) => Number(b.urgent) - Number(a.urgent));
|
||||
}
|
||||
|
||||
const laneLabel = (b: ShippingLineBooking) =>
|
||||
`${b.originYard?.label ?? b.originYard?.code ?? "—"} → ${
|
||||
b.destinationYard?.label ?? b.destinationYard?.code ?? "—"
|
||||
}`;
|
||||
|
||||
const timeGreeting = () => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return "Good morning";
|
||||
if (h < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
};
|
||||
|
||||
/** Whole days from now to `date`, floored at 0 (today). */
|
||||
const daysUntil = (date: string) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.ceil((new Date(date).getTime() - Date.now()) / 86_400_000),
|
||||
);
|
||||
|
||||
/* ── Page ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Shipping-line home. Mirrors the customer dashboard's anatomy — greeting +
|
||||
* primary CTA, KPI band, "needs your attention" queue — but its centrepiece is
|
||||
* the departure board: the line's next dedicated train, which no customer ever
|
||||
* sees, so this page is where it must feel real.
|
||||
*/
|
||||
export default function ShippingLineHomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { shippingLine } = useAuth();
|
||||
// The hook's union type collapses on direct property access; the account is
|
||||
// positively a shipping line on these routes, so the cast is safe.
|
||||
const lineName =
|
||||
(shippingLine as { name?: string } | null)?.name ?? "Shipping line";
|
||||
const [initiateOpen, setInitiateOpen] = useState(false);
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
queryFn: shippingLineBookingsService.list,
|
||||
});
|
||||
const trainsQuery = useQuery({
|
||||
queryKey: ["shipping-line-my-trains"],
|
||||
queryFn: shippingLineBookingsService.myTrains,
|
||||
});
|
||||
|
||||
const bookings = useMemo(
|
||||
() => bookingsQuery.data ?? [],
|
||||
[bookingsQuery.data],
|
||||
);
|
||||
const trains = trainsQuery.data ?? [];
|
||||
const actions = useMemo(() => deriveActions(bookings), [bookings]);
|
||||
|
||||
const activeBookings = bookings.filter(
|
||||
(b) => (b.status as string) !== "CANCELLED",
|
||||
);
|
||||
const inReview = bookings.filter(
|
||||
(b) =>
|
||||
!b.hasQueriedDocuments &&
|
||||
["DOCUMENTS_UNDER_REVIEW", "OPERATION_REQUEST_PENDING"].includes(
|
||||
b.status as string,
|
||||
),
|
||||
);
|
||||
|
||||
const upcoming = trains.filter(
|
||||
(t) => new Date(t.scheduledDepartureDate).getTime() > Date.now() - 3_600_000,
|
||||
);
|
||||
const nextTrain = upcoming[0] ?? null;
|
||||
const laterTrains = nextTrain ? upcoming.slice(1) : upcoming;
|
||||
|
||||
// Trains this line has BOOKED: allocated ones by trainScheduleId, plus —
|
||||
// before allocation lands — any live booking on the same lane + shipment
|
||||
// day. The hero shows EVERY booked departure of the next booked day, not
|
||||
// just the first train.
|
||||
const isSameDay = (a: string | Date, b: string | Date) =>
|
||||
new Date(a).toDateString() === new Date(b).toDateString();
|
||||
const bookedUpcoming = upcoming.filter((t) =>
|
||||
activeBookings.some(
|
||||
(b) =>
|
||||
b.trainScheduleId === t.id ||
|
||||
(b.scheduledDate &&
|
||||
isSameDay(b.scheduledDate, t.scheduledDepartureDate) &&
|
||||
b.originYard?.id === t.originYardId &&
|
||||
b.destinationYard?.id === t.destinationYardId),
|
||||
),
|
||||
);
|
||||
const nextBookedDay = bookedUpcoming[0]
|
||||
? new Date(bookedUpcoming[0].scheduledDepartureDate).toDateString()
|
||||
: null;
|
||||
const nextDayDepartures = nextBookedDay
|
||||
? bookedUpcoming.filter((t) =>
|
||||
isSameDay(t.scheduledDepartureDate, nextBookedDay),
|
||||
)
|
||||
: [];
|
||||
// Booked departures take the hero; with none, fall back to the line's next
|
||||
// dedicated train so the board never goes blank while trains exist.
|
||||
const heroTrains = nextDayDepartures.length
|
||||
? nextDayDepartures
|
||||
: nextTrain
|
||||
? [nextTrain]
|
||||
: [];
|
||||
const heroIsBooked = nextDayDepartures.length > 0;
|
||||
const heroFirst = heroTrains[0] ?? null;
|
||||
|
||||
const goToAction = (a: PendingAction) =>
|
||||
navigate(
|
||||
a.kind === "book" || a.kind === "resubmit"
|
||||
? `/shipping-line/bookings/${a.booking.id}/complete`
|
||||
: `/shipping-line/bookings/${a.booking.id}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
{/* Greeting + the one primary action, same shape as the customer home. */}
|
||||
<Group justify="space-between" align="center" gap="md">
|
||||
<Box>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{timeGreeting()}
|
||||
</Text>
|
||||
<Text fz={26} fw={800} c="edr-text" className="tracking-tight" mt={2}>
|
||||
{lineName} ⚓
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => setInitiateOpen(true)}
|
||||
className="w-full cursor-pointer border-none bg-transparent p-0 text-left md:w-auto"
|
||||
>
|
||||
<Group
|
||||
gap={14}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
bg="edr-green"
|
||||
px={18}
|
||||
py={14}
|
||||
className="w-full md:w-60! rounded-2xl shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
|
||||
>
|
||||
<PackagePlus size={22} color="#fff" />
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fz={14} fw={700} c="white" lh={1.3}>
|
||||
Initiate a booking
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
|
||||
<ArrowRight size={18} color={cv("edr-green.7")} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* KPI band — one card, four figures that answer "where do I stand". */}
|
||||
<Card>
|
||||
<Box className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
|
||||
<StatKpi
|
||||
icon={Package}
|
||||
label="Active bookings"
|
||||
value={String(activeBookings.length)}
|
||||
delta=""
|
||||
accent="green"
|
||||
loading={bookingsQuery.isPending}
|
||||
/>
|
||||
<StatKpi
|
||||
icon={AlertTriangle}
|
||||
label="Waiting on you"
|
||||
value={String(actions.length)}
|
||||
delta={actions.length > 0 ? "action needed" : "all clear"}
|
||||
accent="amber"
|
||||
deltaTone={actions.length > 0 ? "amber" : "muted"}
|
||||
divider
|
||||
loading={bookingsQuery.isPending}
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Clock3}
|
||||
label="In review with EDR"
|
||||
value={String(inReview.length)}
|
||||
delta=""
|
||||
accent="blue"
|
||||
divider
|
||||
loading={bookingsQuery.isPending}
|
||||
/>
|
||||
<StatKpi
|
||||
icon={TrainFront}
|
||||
label="Upcoming trains"
|
||||
value={String(upcoming.length)}
|
||||
delta=""
|
||||
accent="slate"
|
||||
divider
|
||||
loading={trainsQuery.isPending}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Bookings waiting on the line — urgent first, one click to the fix. */}
|
||||
{actions.length > 0 && (
|
||||
<Card padding={0}>
|
||||
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
|
||||
<Group gap={8}>
|
||||
<AlertTriangle size={18} className="text-amber-500" />
|
||||
<Text fw={700} fz={16} c="edr-text">
|
||||
Needs your attention
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color="orange" variant="light" radius="sm">
|
||||
{actions.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
{actions.map((a, i) => {
|
||||
const meta = ACTION_META[a.kind];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Group
|
||||
key={a.booking.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={14}
|
||||
style={{
|
||||
borderTop:
|
||||
i === 0 ? "none" : "1px solid var(--mantine-color-gray-2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => goToAction(a)}
|
||||
className="hover:bg-edr-soft"
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: `var(--mantine-color-${meta.color}-light)`,
|
||||
color: `var(--mantine-color-${meta.color}-filled)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||
{a.booking.reference}
|
||||
</Text>
|
||||
{a.urgent && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
Action required
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" truncate>
|
||||
{laneLabel(a.booking)} · {a.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={a.urgent ? "filled" : "light"}
|
||||
color={meta.color}
|
||||
radius="md"
|
||||
>
|
||||
{meta.button}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* The departure board — every departure the line has BOOKED on its
|
||||
next booked day (falling back to the next dedicated train when
|
||||
nothing is booked yet). These trains are invisible to customers, so
|
||||
this is the one place they surface; give them real presence. */}
|
||||
{heroFirst && (
|
||||
<Box
|
||||
className="rounded-[20px]"
|
||||
p={{ base: 20, sm: 28 }}
|
||||
style={{
|
||||
background: `linear-gradient(120deg, ${cv("edr-green.9")} 0%, ${cv(
|
||||
"edr-green.7",
|
||||
)} 60%, ${cv("edr-green.6")} 100%)`,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg">
|
||||
<Box>
|
||||
<Group gap={8} mb={6}>
|
||||
<TrainFront size={16} color="rgba(255,255,255,0.85)" />
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.12em", color: "rgba(255,255,255,0.85)" }}
|
||||
>
|
||||
{heroIsBooked
|
||||
? heroTrains.length > 1
|
||||
? "Your booked departures"
|
||||
: "Your next departure"
|
||||
: "Your next departure"}
|
||||
</Text>
|
||||
{heroIsBooked && (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
>
|
||||
{heroTrains.length > 1
|
||||
? `${heroTrains.length} trains`
|
||||
: "Booked"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={{ base: 28, sm: 34 }} fw={800} lh={1.1} c="white">
|
||||
{new Date(heroFirst.scheduledDepartureDate).toLocaleDateString(
|
||||
undefined,
|
||||
{ weekday: "long", month: "long", day: "numeric" },
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack gap={10} align="flex-end">
|
||||
<Group
|
||||
gap={8}
|
||||
px={14}
|
||||
py={8}
|
||||
className="rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.16)" }}
|
||||
>
|
||||
<CalendarClock size={15} color="#fff" />
|
||||
<Text fz={13} fw={700} c="white">
|
||||
{daysUntil(heroFirst.scheduledDepartureDate) === 0
|
||||
? "Departs today"
|
||||
: daysUntil(heroFirst.scheduledDepartureDate) === 1
|
||||
? "Departs tomorrow"
|
||||
: `Departs in ${daysUntil(
|
||||
heroFirst.scheduledDepartureDate,
|
||||
)} days`}
|
||||
</Text>
|
||||
</Group>
|
||||
{!heroIsBooked && (
|
||||
<Text fz={11.5} style={{ color: "rgba(255,255,255,0.75)" }}>
|
||||
Departs{" "}
|
||||
{new Date(
|
||||
heroFirst.scheduledDepartureDate,
|
||||
).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}{" "}
|
||||
· bookable until the cut-off before departure
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Every departure of that day — one row per train. */}
|
||||
<Stack gap={8} mt={16}>
|
||||
{heroTrains.map((t) => (
|
||||
<Group
|
||||
key={t.id}
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="sm"
|
||||
px={14}
|
||||
py={10}
|
||||
className="rounded-xl"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<MapPin size={15} color="rgba(255,255,255,0.85)" />
|
||||
<Text fz={14} fw={600} style={{ color: "rgba(255,255,255,0.95)" }}>
|
||||
{t.originLabel} → {t.destinationLabel}
|
||||
{t.trainNumber
|
||||
? ` · Train ${t.trainNumber}`
|
||||
: t.reference
|
||||
? ` · ${t.reference}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={10}>
|
||||
<Text fz={13} fw={700} c="white">
|
||||
{new Date(t.scheduledDepartureDate).toLocaleTimeString(
|
||||
undefined,
|
||||
{ hour: "2-digit", minute: "2-digit" },
|
||||
)}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Trains + recent bookings, side by side. */}
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Card padding={0} className="h-full">
|
||||
<Group justify="space-between" px={24} pt={20} pb={12}>
|
||||
<Group gap={8}>
|
||||
<TrainFront size={17} color={cv("edr-green.7")} />
|
||||
<Text fw={700} fz={16} c="edr-text">
|
||||
Your trains
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{upcoming.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
{trainsQuery.isPending ? (
|
||||
<Stack gap={10} px={24} pb={20}>
|
||||
<Skeleton height={54} radius="md" />
|
||||
<Skeleton height={54} radius="md" />
|
||||
</Stack>
|
||||
) : upcoming.length === 0 ? (
|
||||
<TrainsEmpty />
|
||||
) : (
|
||||
<Stack gap={0} pb={8}>
|
||||
{(nextTrain ? [nextTrain, ...laterTrains] : laterTrains).map(
|
||||
(t, i) => (
|
||||
<TrainRow key={t.id} train={t} first={i === 0} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Card padding={0} className="h-full">
|
||||
<Group justify="space-between" px={24} pt={20} pb={12}>
|
||||
<Group gap={8}>
|
||||
<FileText size={17} color={cv("edr-blue")} />
|
||||
<Text fw={700} fz={16} c="edr-text">
|
||||
Recent bookings
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
onClick={() => navigate("/shipping-line/bookings")}
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Group>
|
||||
{bookingsQuery.isPending ? (
|
||||
<Stack gap={10} px={24} pb={20}>
|
||||
<Skeleton height={54} radius="md" />
|
||||
<Skeleton height={54} radius="md" />
|
||||
<Skeleton height={54} radius="md" />
|
||||
</Stack>
|
||||
) : bookings.length === 0 ? (
|
||||
<BookingsEmpty onInitiate={() => setInitiateOpen(true)} />
|
||||
) : (
|
||||
<Stack gap={0} pb={8}>
|
||||
{bookings.slice(0, 6).map((b, i) => (
|
||||
<Group
|
||||
key={b.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={12}
|
||||
style={{
|
||||
borderTop:
|
||||
i === 0
|
||||
? "none"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="hover:bg-edr-soft"
|
||||
onClick={() => navigate(`/shipping-line/bookings/${b.id}`)}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text fz={12.5} c="dimmed" truncate>
|
||||
{laneLabel(b)}
|
||||
{b.scheduledDate
|
||||
? ` · ships ${new Date(
|
||||
b.scheduledDate,
|
||||
).toLocaleDateString()}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<StatusBadge status={b.status as string} />
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Quick links — the rest of the portal, one hop away. */}
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<QuickLink
|
||||
icon={PackageCheck}
|
||||
title="My bookings"
|
||||
hint="Track every booking and its documents"
|
||||
onClick={() => navigate("/shipping-line/bookings")}
|
||||
/>
|
||||
<QuickLink
|
||||
icon={Receipt}
|
||||
title="Invoices"
|
||||
hint="Charges on your credit account"
|
||||
onClick={() => navigate("/shipping-line/invoices")}
|
||||
/>
|
||||
<QuickLink
|
||||
icon={LifeBuoy}
|
||||
title="Help & support"
|
||||
hint="Guides and contact channels"
|
||||
onClick={() => navigate("/shipping-line/help")}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ShippingLineInitiateModal
|
||||
opened={initiateOpen}
|
||||
onClose={() => setInitiateOpen(false)}
|
||||
onCreated={(booking) => {
|
||||
setInitiateOpen(false);
|
||||
navigate(`/shipping-line/bookings/${booking.id}`);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Small pieces ──────────────────────────────────────────────────────── */
|
||||
|
||||
function TrainRow({
|
||||
train,
|
||||
first,
|
||||
}: {
|
||||
train: ShippingLineTrain;
|
||||
first: boolean;
|
||||
}) {
|
||||
const departure = new Date(train.scheduledDepartureDate);
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={12}
|
||||
style={{
|
||||
borderTop: first ? "none" : "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: cv("edr-soft"),
|
||||
color: cv("edr-green.7"),
|
||||
}}
|
||||
>
|
||||
<TrainFront size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||
{train.trainNumber ?? train.reference ?? "Train"}
|
||||
</Text>
|
||||
<Text fz={12.5} c="dimmed" truncate>
|
||||
{train.originLabel} → {train.destinationLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Box style={{ textAlign: "right", flexShrink: 0 }}>
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{departure.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
<Text fz={11.5} c="dimmed">
|
||||
{departure.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function TrainsEmpty() {
|
||||
return (
|
||||
<Stack align="center" gap={6} py={36} px={24}>
|
||||
<TrainFront size={26} color={cv("edr-slate")} />
|
||||
<Text fz={13.5} c="dimmed" ta="center">
|
||||
No trains assigned to you yet — Operations dedicates departures to your
|
||||
line and they appear here.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingsEmpty({ onInitiate }: { onInitiate: () => void }) {
|
||||
return (
|
||||
<Stack align="center" gap={10} py={32} px={24}>
|
||||
<Package size={26} color={cv("edr-slate")} />
|
||||
<Text fz={13.5} c="dimmed" ta="center">
|
||||
No bookings yet. Initiate one — you upload documents next, and book the
|
||||
cargo once they are approved.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="compact-md"
|
||||
leftSection={<PackagePlus size={15} />}
|
||||
onClick={onInitiate}
|
||||
>
|
||||
Initiate a booking
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLink({
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof Receipt;
|
||||
title: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
onClick={onClick}
|
||||
className="cursor-pointer rounded-[16px] border border-edr-border bg-edr-card p-4 text-left transition-colors hover:bg-edr-soft"
|
||||
>
|
||||
<Group gap={12} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: cv("edr-soft"),
|
||||
color: cv("edr-green.7"),
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700} fz={14} c="edr-text">
|
||||
{title}
|
||||
</Text>
|
||||
<ArrowRight size={14} color={cv("edr-muted")} />
|
||||
</Group>
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, CalendarDays, MapPin, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
shippingLineBookingsService,
|
||||
type ShippingLineBooking,
|
||||
} from "@/services/shipping-line-bookings.service";
|
||||
|
||||
const FREIGHT_TYPES = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Initiate a shipping-line booking.
|
||||
*
|
||||
* Origin and destination are picked separately (matching the customer form),
|
||||
* but both lists are drawn from real routes, so the pair always resolves to a
|
||||
* lane EDR runs. The request still sends that route's id, letting the server
|
||||
* derive origin, destination and direction from one authoritative row.
|
||||
*
|
||||
* Only inbound (Djibouti to Ethiopia) lanes are offered — the API filters them
|
||||
* and rejects anything else, so this is a fixed rule, not a UI convenience.
|
||||
*/
|
||||
export default function ShippingLineInitiateModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (booking: ShippingLineBooking) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
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(() => {
|
||||
if (opened) {
|
||||
setOriginYardId(null);
|
||||
setDestinationYardId(null);
|
||||
setServiceTypeId(null);
|
||||
setFreightType("CONTAINER");
|
||||
setScheduledDate(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const referenceQuery = useQuery({
|
||||
queryKey: ["shipping-line-reference-data"],
|
||||
queryFn: shippingLineBookingsService.referenceData,
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const initiateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
shippingLineBookingsService.initiate({
|
||||
routeId: selectedRoute!.id,
|
||||
serviceTypeId: serviceTypeId ?? undefined,
|
||||
freightType,
|
||||
scheduledDate: scheduledDate ?? undefined,
|
||||
}),
|
||||
onSuccess: (booking) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
onCreated(booking);
|
||||
},
|
||||
});
|
||||
|
||||
const routes = useMemo(
|
||||
() => referenceQuery.data?.routes ?? [],
|
||||
[referenceQuery.data],
|
||||
);
|
||||
const serviceTypes = referenceQuery.data?.serviceTypes ?? [];
|
||||
|
||||
// Origin and destination are picked separately (as in the customer form), but
|
||||
// the pair still has to be a route EDR actually runs — so each list is drawn
|
||||
// from the routes, and the destination list narrows to what the chosen origin
|
||||
// can reach. That keeps the familiar two-field shape without letting someone
|
||||
// assemble a lane that does not exist.
|
||||
const originOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const route of routes) {
|
||||
if (!seen.has(route.originYardId)) {
|
||||
seen.set(route.originYardId, route.originLabel);
|
||||
}
|
||||
}
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [routes]);
|
||||
|
||||
const destinationOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const route of routes) {
|
||||
if (originYardId && route.originYardId !== originYardId) continue;
|
||||
if (!seen.has(route.destinationYardId)) {
|
||||
seen.set(route.destinationYardId, route.destinationLabel);
|
||||
}
|
||||
}
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [routes, originYardId]);
|
||||
|
||||
// The lane the two picks resolve to. Still sent as a routeId so the server
|
||||
// keeps deriving origin/destination/direction from one authoritative row.
|
||||
const selectedRoute = routes.find(
|
||||
(r) =>
|
||||
r.originYardId === originYardId &&
|
||||
r.destinationYardId === destinationYardId,
|
||||
);
|
||||
|
||||
// Changing the origin can invalidate an already-picked destination.
|
||||
useEffect(() => {
|
||||
if (
|
||||
destinationYardId &&
|
||||
!destinationOptions.some((o) => o.value === destinationYardId)
|
||||
) {
|
||||
setDestinationYardId(null);
|
||||
}
|
||||
}, [destinationOptions, destinationYardId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="md"
|
||||
radius="md"
|
||||
title={
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Initiate booking
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
Pick the lane — you will upload documents next.
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
{referenceQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : routes.length === 0 ? (
|
||||
<Text fz="13px" c="dimmed" py="md">
|
||||
No inbound routes are available for booking right now. Please contact
|
||||
Operations.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start" gap="sm">
|
||||
<Select
|
||||
label="Origin yard (Djibouti)"
|
||||
placeholder="Select origin..."
|
||||
withAsterisk
|
||||
searchable
|
||||
data={originOptions}
|
||||
value={originYardId}
|
||||
onChange={setOriginYardId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard (Ethiopia)"
|
||||
placeholder="Select destination..."
|
||||
withAsterisk
|
||||
searchable
|
||||
disabled={!originYardId}
|
||||
data={destinationOptions}
|
||||
value={destinationYardId}
|
||||
onChange={setDestinationYardId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Shipping lines only move inbound cargo, so the direction is fixed
|
||||
rather than derived per pick — stated up front so the single
|
||||
option in each list does not read as missing data. */}
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
p="xs"
|
||||
icon={<MapPin size={15} />}
|
||||
>
|
||||
<Text fz={12}>
|
||||
Inbound only — cargo moves from Djibouti to Ethiopia (
|
||||
<Text span fw={700}>
|
||||
IMPORT
|
||||
</Text>
|
||||
).
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label="Freight type"
|
||||
data={FREIGHT_TYPES}
|
||||
value={freightType}
|
||||
onChange={(v) => setFreightType(v ?? "CONTAINER")}
|
||||
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
|
||||
missing form rather than a data gap. */}
|
||||
{serviceTypes.length > 0 ? (
|
||||
<Select
|
||||
label="Service"
|
||||
placeholder="Select a service"
|
||||
clearable
|
||||
data={serviceTypes.map((s) => ({ value: s.id, label: s.name }))}
|
||||
value={serviceTypeId}
|
||||
onChange={setServiceTypeId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Service"
|
||||
placeholder="No non-customs service configured"
|
||||
disabled
|
||||
data={[]}
|
||||
value={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{initiateMutation.isError && (
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
{(initiateMutation.error as Error)?.message ??
|
||||
"Could not initiate 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={<Plus size={16} />}
|
||||
loading={initiateMutation.isPending}
|
||||
disabled={!selectedRoute || !scheduledDate}
|
||||
onClick={() => initiateMutation.mutate()}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Freight } from "@edr/types";
|
||||
import { ChevronRight, Info, Receipt } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { api } from "@/services/api";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import { fmtDate, InvoiceStatusBadge, isPayable } from "../billing/invoice-ui";
|
||||
|
||||
function StatBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
c={MUTED}
|
||||
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={22} fw={800} mt={4} style={{ color: INK }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipping line's credit invoices — Finance bills batches of the line's
|
||||
* booking charges, and each invoice is payable at any CBE channel whenever the
|
||||
* line chooses; there is no payment window. Detail (which bookings, each
|
||||
* charge, the total) lives one click deeper on the shared invoice detail page.
|
||||
*/
|
||||
export default function ShippingLineInvoicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: allInvoices,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery(api.invoices.listMy.queryOptions());
|
||||
|
||||
// Only the credit invoices: a shipping line's bookings are charged through
|
||||
// the credit ledger, so other sources (e.g. a booking's internal draft
|
||||
// invoice) would only double-state the same debt here.
|
||||
const invoices = useMemo(
|
||||
() =>
|
||||
(allInvoices ?? []).filter(
|
||||
(inv) => inv.source === Freight.InvoiceSource.ShippingLineCredit,
|
||||
),
|
||||
[allInvoices],
|
||||
);
|
||||
|
||||
const openInvoices = invoices.filter((inv) => isPayable(inv.status));
|
||||
const outstanding = openInvoices.reduce(
|
||||
(sum, inv) => sum + Number(inv.balanceAmount ?? inv.totalAmount),
|
||||
0,
|
||||
);
|
||||
const currency = invoices[0]?.currency ?? "ETB";
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Invoices</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Your billed freight charges. Pay any open invoice at a CBE branch,
|
||||
the CBE app or USSD using its bill reference — there is no payment
|
||||
deadline window.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<StatBox
|
||||
label="Outstanding balance"
|
||||
value={formatCurrency(outstanding, currency)}
|
||||
/>
|
||||
<StatBox label="Open invoices" value={String(openInvoices.length)} />
|
||||
<StatBox
|
||||
label="Paid invoices"
|
||||
value={String(
|
||||
invoices.filter(
|
||||
(inv) => inv.status === Freight.InvoiceStatus.Paid,
|
||||
).length,
|
||||
)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py={64}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Alert color="red" title="Could not load invoices">
|
||||
<Group gap="sm">
|
||||
<Text size="sm">Something went wrong.</Text>
|
||||
<Button size="compact-sm" variant="light" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : invoices.length === 0 ? (
|
||||
<Card withBorder radius="md" py={64}>
|
||||
<Stack align="center" gap="xs">
|
||||
<Receipt size={28} className="text-slate-300" />
|
||||
<Text c="dimmed" size="sm">
|
||||
No invoices yet. Finance bills your accumulated booking charges
|
||||
in batches — invoices will appear here once issued.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
highlightOnHover
|
||||
verticalSpacing={12}
|
||||
horizontalSpacing={20}
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: MUTED,
|
||||
background: "#F8FAFC",
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
td: { borderBottom: `1px solid ${BORDER}` },
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Invoice</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Due</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
<Table.Th ta="right">Balance</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{invoices.map((inv) => (
|
||||
<Table.Tr
|
||||
key={inv.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(`/shipping-line/invoices/${inv.id}`)
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} ff="monospace" style={{ color: INK }}>
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13}>{fmtDate(inv.issuedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13}>{fmtDate(inv.dueAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} fw={600}>
|
||||
{formatCurrency(Number(inv.totalAmount), inv.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} fw={600}>
|
||||
{formatCurrency(
|
||||
Number(inv.balanceAmount ?? inv.totalAmount),
|
||||
inv.currency,
|
||||
)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={isPayable(inv.status) ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
rightSection={<ChevronRight size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/shipping-line/invoices/${inv.id}`);
|
||||
}}
|
||||
>
|
||||
{isPayable(inv.status) ? "View & pay" : "View"}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
Open an invoice and choose <b>Pay</b> → <b>CBE</b> to get its bill
|
||||
reference. Pay against that reference at any CBE branch, in the CBE
|
||||
app or via USSD; the invoice settles automatically once CBE confirms.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Card, Stack, Text, Title } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ShippingLinePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell for the shipping-line pages while they are still being built out.
|
||||
* Each page owns its own file so it can be filled in independently; this only
|
||||
* supplies the shared empty-state chrome and is meant to be deleted from a page
|
||||
* once that page has real content.
|
||||
*/
|
||||
export default function ShippingLinePlaceholder({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: ShippingLinePlaceholderProps) {
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>{title}</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card withBorder radius="md" py={64}>
|
||||
<Stack align="center" gap="xs">
|
||||
{icon}
|
||||
<Text c="dimmed" size="sm">
|
||||
Nothing here yet — this page is still being built.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line settings. Separate from the customer `SettingsPage`, which is
|
||||
* built around company profiles, business licenses and contact-person review —
|
||||
* none of which a shipping line has.
|
||||
*/
|
||||
export default function ShippingLineSettingsPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Settings"
|
||||
description="Manage your account and preferences."
|
||||
icon={<Settings size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
/**
|
||||
* What the shipping line has to do about a booking's documents.
|
||||
*
|
||||
* Derived from the booking status AND the per-document review results, because
|
||||
* the two disagree in the case that matters most: when a reviewer queries a
|
||||
* document, that document's review status becomes QUERIED but the BOOKING stays
|
||||
* on DOCUMENTS_UNDER_REVIEW. Keying the UI off status alone would keep showing
|
||||
* "In review" while the shipping line is actually being asked to fix something.
|
||||
*/
|
||||
export type BookingDocState =
|
||||
/** Nothing uploaded yet — the first submission is owed. */
|
||||
| "AWAITING"
|
||||
/** A reviewer sent something back; the shipping line must re-upload. */
|
||||
| "ACTION_NEEDED"
|
||||
/** Submitted and with Operations. */
|
||||
| "IN_REVIEW"
|
||||
/** Everything approved. */
|
||||
| "APPROVED"
|
||||
/** Documents do not apply at this status. */
|
||||
| "NONE";
|
||||
|
||||
export function bookingDocState(
|
||||
booking: Pick<ShippingLineBooking, "status" | "hasQueriedDocuments">,
|
||||
): BookingDocState {
|
||||
const status = booking.status as string;
|
||||
|
||||
// Checked before the status switch: a queried document outranks the booking's
|
||||
// own DOCUMENTS_UNDER_REVIEW, which is exactly the case status alone misses.
|
||||
if (booking.hasQueriedDocuments) return "ACTION_NEEDED";
|
||||
|
||||
switch (status) {
|
||||
case "AWAITING_DOCUMENTS":
|
||||
return "AWAITING";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "ACTION_NEEDED";
|
||||
case "DOCUMENTS_UNDER_REVIEW":
|
||||
return "IN_REVIEW";
|
||||
case "CLEARANCE_READY":
|
||||
return "APPROVED";
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the document grid is worth opening at this state. */
|
||||
export function hasDocuments(state: BookingDocState): boolean {
|
||||
return state !== "NONE";
|
||||
}
|
||||
|
||||
/** Whether the shipping line owes an upload — drives the primary action label. */
|
||||
export function needsUpload(state: BookingDocState): boolean {
|
||||
return state === "AWAITING" || state === "ACTION_NEEDED";
|
||||
}
|
||||
|
||||
export const DOC_STATE_LABEL: Record<BookingDocState, string> = {
|
||||
AWAITING: "Documents needed",
|
||||
ACTION_NEEDED: "Action needed",
|
||||
IN_REVIEW: "In review",
|
||||
APPROVED: "Approved",
|
||||
NONE: "",
|
||||
};
|
||||
|
||||
/** Mantine colour for the state's badge/alert. */
|
||||
export const DOC_STATE_COLOR: Record<BookingDocState, string> = {
|
||||
AWAITING: "yellow",
|
||||
ACTION_NEEDED: "red",
|
||||
IN_REVIEW: "blue",
|
||||
APPROVED: "teal",
|
||||
NONE: "gray",
|
||||
};
|
||||
|
||||
/**
|
||||
* Label for the button that opens the document grid. A queried document asks
|
||||
* for a replacement, so it reads as an instruction rather than "View" — the
|
||||
* shipping line must swap the file, not just look at it.
|
||||
*/
|
||||
export const DOC_STATE_ACTION_LABEL: Record<BookingDocState, string> = {
|
||||
AWAITING: "Upload documents",
|
||||
ACTION_NEEDED: "Change document",
|
||||
IN_REVIEW: "View documents",
|
||||
APPROVED: "View documents",
|
||||
NONE: "View documents",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
||||
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
||||
export { default as ShippingLineBookingDetailPage } from "./ShippingLineBookingDetailPage";
|
||||
export { default as ShippingLineCompletePage } from "./ShippingLineCompletePage";
|
||||
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
||||
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
||||
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
import type {
|
||||
AccountInfoResponse,
|
||||
ChangeRequestResponse,
|
||||
CompanyDocument,
|
||||
CompanyInfoResponse,
|
||||
@@ -188,7 +189,7 @@ export const api = {
|
||||
},
|
||||
|
||||
companies: {
|
||||
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||
getInfo: endpoint<void, AccountInfoResponse | null>(
|
||||
"companies",
|
||||
"getInfo",
|
||||
companiesService.getInfo,
|
||||
|
||||
@@ -89,7 +89,18 @@ export interface CompanyProfileResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which kind of account is signed in.
|
||||
*
|
||||
* Read this instead of inferring from a missing `company`: a failed or
|
||||
* in-flight company fetch also leaves `company` empty, and treating that as
|
||||
* "shipping line" would skip onboarding for customers whenever the request
|
||||
* failed. Absent (older responses) means `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
|
||||
export interface CompanyInfoResponse {
|
||||
accountKind?: AccountKind;
|
||||
profile: ExternalProfileResponse;
|
||||
company: CompanyResponse;
|
||||
/**
|
||||
@@ -104,6 +115,30 @@ export interface CompanyInfoResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in shipping line. It has no company, no external profile and no
|
||||
* onboarding — the carrier record itself is the account.
|
||||
*/
|
||||
export interface ShippingLineInfoResponse {
|
||||
accountKind: "shipping_line";
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
status: string;
|
||||
company: null;
|
||||
profile: null;
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves both portal audiences. */
|
||||
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
|
||||
|
||||
export const isShippingLineAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
|
||||
|
||||
/** A staged profile-edit review request (portal view). */
|
||||
export interface ChangeRequestResponse {
|
||||
id: string;
|
||||
@@ -249,9 +284,9 @@ export interface DashboardSummary {
|
||||
}
|
||||
|
||||
export const companiesService = {
|
||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||
getInfo: async (): Promise<AccountInfoResponse | null> => {
|
||||
try {
|
||||
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
|
||||
const response = await client.get<ApiResponse<AccountInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
const BASE = "/api/shipping-line-bookings";
|
||||
|
||||
/**
|
||||
* The `code` of the file-upload setting whose fields a shipping line fills in
|
||||
* after initiating a booking. Configured in the backoffice file-settings editor
|
||||
* (Other tab) and seeded by `file-upload-settings.seeder.ts`.
|
||||
*/
|
||||
export const SHIPPING_LINE_BOOKING_DOCUMENTS_CODE =
|
||||
"shipping_line_booking_documents";
|
||||
|
||||
/** One physical container persisted on a booking line. */
|
||||
export interface ShippingLineBookingContainerUnit {
|
||||
id: string;
|
||||
containerNumber?: string | null;
|
||||
sealNumber?: string | null;
|
||||
vgmTons?: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
/** One persisted container line of a completed booking. */
|
||||
export interface ShippingLineBookingContainer {
|
||||
id: string;
|
||||
containerSize?: string | null;
|
||||
quantity: number;
|
||||
vgmPerUnitTons?: number;
|
||||
totalVgmTons?: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
wagonsRequired?: number;
|
||||
containerType?: {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
code?: string | null;
|
||||
sizeFt?: number | null;
|
||||
} | null;
|
||||
units?: ShippingLineBookingContainerUnit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping-line booking as the portal sees it.
|
||||
*
|
||||
* `hasQueriedDocuments` is computed server-side: a reviewer querying a document
|
||||
* leaves the booking on DOCUMENTS_UNDER_REVIEW, so the booking status alone
|
||||
* cannot tell the UI that the shipping line has something to fix. The cargo
|
||||
* fields are loaded by the detail endpoint for the detail page's cargo tab.
|
||||
*/
|
||||
export type ShippingLineBooking = Freight.IBooking & {
|
||||
hasQueriedDocuments?: boolean;
|
||||
/** Operations' note when the request was returned for changes (detail only). */
|
||||
operationChangeNote?: string | null;
|
||||
bookingContainers?: ShippingLineBookingContainer[];
|
||||
cargoType?: { id: string; cargoTypeName?: string | null } | null;
|
||||
cargoFreeText?: string | null;
|
||||
isHazardous?: boolean;
|
||||
cargoTotalWeightVgm?: number;
|
||||
bulkTotalWeightTons?: number | null;
|
||||
};
|
||||
|
||||
/** The authoritative quote returned by the price-preview endpoint. */
|
||||
export interface ShippingLinePriceQuote {
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
lineItems: Freight.PricingBreakdownLineItem[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One wagon the batch engine allocated to the booking. */
|
||||
export interface ShippingLineBookingWagon {
|
||||
id: string;
|
||||
status: string;
|
||||
loadType?: string | null;
|
||||
allocatedWeightTons: number;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
capacityTons: number;
|
||||
containerNumbers: string[];
|
||||
}
|
||||
|
||||
/** Operations view: the train the booking rides + its allocated wagons. */
|
||||
export interface ShippingLineBookingOperations {
|
||||
train: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
trainNumber?: string | null;
|
||||
status: string;
|
||||
direction?: string | null;
|
||||
scheduledDepartureDate: string;
|
||||
scheduledArrivalDate?: string | null;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** true once operations assigned it; false while it is only requested. */
|
||||
assigned: boolean;
|
||||
} | null;
|
||||
wagons: ShippingLineBookingWagon[];
|
||||
}
|
||||
|
||||
/** A bookable lane. Its direction is frozen server-side from the yards. */
|
||||
export interface ShippingLineRouteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
direction: string;
|
||||
originYardId: string;
|
||||
originLabel: string;
|
||||
destinationYardId: string;
|
||||
destinationLabel: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route is the only lane input: it yields origin, destination and trade
|
||||
* direction together, so they cannot contradict each other.
|
||||
*/
|
||||
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. */
|
||||
/** One physical container — number, seal, VGM and its handling switches. */
|
||||
export interface CompleteBookingContainerUnit {
|
||||
containerNumber: string;
|
||||
sealNumber?: string;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
export interface CompleteBookingContainerLine {
|
||||
/** Either the type id or a size string ("20ft" | "40ft") — the server resolves size→type. */
|
||||
containerTypeId?: string;
|
||||
containerSize?: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons?: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
/** Per-container rows — when sent, the server derives counts/VGM from them. */
|
||||
units?: CompleteBookingContainerUnit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Which dedicated train the booking rides. Required when more than one of
|
||||
* the line's trains departs on the chosen day; implicit with one departure.
|
||||
*/
|
||||
trainScheduleId?: string;
|
||||
paymentCurrency?: string;
|
||||
containers?: CompleteBookingContainerLine[];
|
||||
cargoTypeId?: string;
|
||||
cargoWeightTons?: number;
|
||||
bulkHazardousQuantity?: number;
|
||||
bulkReeferQuantity?: number;
|
||||
cargoFreeText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipping-line bookings. Separate from `bookings.service.ts` (customers) — the
|
||||
* endpoints differ, and shipping lines book without a contract.
|
||||
*/
|
||||
export const shippingLineBookingsService = {
|
||||
/** Create a bare booking; it starts at AWAITING_DOCUMENTS. */
|
||||
initiate: async (
|
||||
payload: InitiateShippingLineBookingPayload,
|
||||
): Promise<ShippingLineBooking> => {
|
||||
const { data } = await client.post(`${BASE}/initiate`, payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Bookable routes + service types for the initiate form. */
|
||||
referenceData: async (): Promise<ShippingLineReferenceData> => {
|
||||
const { data } = await client.get(`${BASE}/reference-data`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
list: async (): Promise<ShippingLineBooking[]> => {
|
||||
const { data } = await client.get(`${BASE}/my`);
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* The line's dedicated trains for a shipment day, each with per-wagon-type
|
||||
* free space — the completion form's train picker. Cargo context refines
|
||||
* the availability figures.
|
||||
*/
|
||||
trainsForDay: async (
|
||||
id: string,
|
||||
date?: string,
|
||||
cargo?: { containerSizes?: string[]; cargoTypeId?: string; wagons?: number },
|
||||
): Promise<Freight.ExportTrainOption[]> => {
|
||||
const { data } = await client.get(`${BASE}/${id}/trains`, {
|
||||
params: {
|
||||
...(date ? { date } : {}),
|
||||
...(cargo?.containerSizes?.length
|
||||
? { sizes: cargo.containerSizes.join(",") }
|
||||
: {}),
|
||||
...(cargo?.cargoTypeId ? { cargoTypeId: cargo.cargoTypeId } : {}),
|
||||
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
|
||||
},
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Authoritative price quote for the completion payload — the same compute
|
||||
* /complete runs. Server saves the breakdown + rate snapshots on the
|
||||
* booking (refreshed on every re-preview); nothing else is persisted.
|
||||
*/
|
||||
pricePreview: async (
|
||||
id: string,
|
||||
payload: CompleteShippingLineBookingPayload,
|
||||
): Promise<ShippingLinePriceQuote> => {
|
||||
const { data } = await client.post(`${BASE}/${id}/price-preview`, payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The train the booking rides + the wagons allocated to it. */
|
||||
operations: async (id: string): Promise<ShippingLineBookingOperations> => {
|
||||
const { data } = await client.get(`${BASE}/${id}/operations`);
|
||||
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.
|
||||
*/
|
||||
cancel: async (id: string, reason?: string): Promise<ShippingLineBooking> => {
|
||||
const { data } = await client.post(`${BASE}/${id}/cancel`, { reason });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* The document grid for a booking: every configured field with its uploaded
|
||||
* file, review status and reviewer note. Same shared endpoint the customer
|
||||
* clearance flow reads — it resolves the field set from the booking, which
|
||||
* now maps shipping-line bookings to their own file-upload setting.
|
||||
*/
|
||||
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/clearance`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload the booking's documents.
|
||||
*
|
||||
* Posts to the shared CLEARANCE documents endpoint, not `/documents`: the
|
||||
* latter only accepts DRAFT bookings, and these start at AWAITING_DOCUMENTS.
|
||||
* This is the same endpoint the customer clearance flow uses — it files each
|
||||
* document for review and moves the booking to DOCUMENTS_UNDER_REVIEW.
|
||||
*/
|
||||
uploadDocuments: async (
|
||||
id: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
): Promise<ShippingLineBooking> => {
|
||||
const formData = new FormData();
|
||||
for (const [key, fileOrFiles] of Object.entries(files)) {
|
||||
if (!fileOrFiles) continue;
|
||||
if (Array.isArray(fileOrFiles)) {
|
||||
for (const f of fileOrFiles) formData.append(key, f);
|
||||
} else {
|
||||
formData.append(key, fileOrFiles);
|
||||
}
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/documents`,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user