Merge pull request #1279 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-13 21:58:33 +03:00
committed by GitHub
122 changed files with 14852 additions and 316 deletions

View File

@@ -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={

View File

@@ -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>

View File

@@ -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",

View File

@@ -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}

View File

@@ -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>
</>
);
}

View File

@@ -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&apos;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&apos;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>
</>
);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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",
},

View File

@@ -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 ?? [];

View File

@@ -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 makerchecker 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 makerchecker
// 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 (

View File

@@ -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}

View File

@@ -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

View File

@@ -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 &amp; send link
</Button>
</Group>
</Stack>
</form>
</Modal>
</PageContainer>
);
}

View File

@@ -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
* makerchecker 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>
);
}

View File

@@ -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 (makerchecker
* 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>
);
}

View File

@@ -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 &amp; issue
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -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."

View File

@@ -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",

View File

@@ -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);

View File

@@ -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);
},
};

View File

@@ -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);
},
};

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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).

View File

@@ -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>

View File

@@ -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"

View File

@@ -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");

View File

@@ -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,

View File

@@ -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"

View File

@@ -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&apos;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"

View File

@@ -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 } }}
>

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}
</>
);
}

View File

@@ -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" />}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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" />}
/>
);
}

View File

@@ -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",
};

View File

@@ -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";

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
},
};