mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Merge pull request #1279 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -36,6 +36,8 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
|
||||
import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
@@ -317,6 +319,24 @@ const App = () => {
|
||||
OR'd across both keys so a user with just one still gets in; each
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
<Route
|
||||
path="shipping-lines"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||
<ShippingLineCompaniesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipping-line-credits"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.shippingLineCredits.view}
|
||||
>
|
||||
<ShippingLineCreditsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
// Booking-level flags OR any container line carrying a count — the flag can
|
||||
// lag the lines (per-line opt-ins), so either alone must light the tile.
|
||||
const isHazardous =
|
||||
booking.isHazardous ||
|
||||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
const isReefer =
|
||||
booking.isReefer ||
|
||||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
|
||||
const showHandlingColumns = containers.some(
|
||||
(c) =>
|
||||
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
value={isHazardous ? "Yes" : "No"}
|
||||
highlight={isHazardous}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Refrigerated"
|
||||
value={isReefer ? "Yes" : "No"}
|
||||
highlight={isReefer}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Handling that changes how the yard treats the shipment is flagged
|
||||
loudly, not buried in the grid. */}
|
||||
{(isHazardous || isReefer) && (
|
||||
<Box mt="sm">
|
||||
{isHazardous && (
|
||||
<Badge color="red" variant="filled" radius="sm" mr={8}>
|
||||
Hazardous cargo
|
||||
</Badge>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Badge color="blue" variant="filled" radius="sm">
|
||||
Refrigerated cargo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
<Table.Th>Container type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
|
||||
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
</Table.Td>
|
||||
<Table.Td>{c.quantity}</Table.Td>
|
||||
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.hazardousQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="red" size="sm">
|
||||
{c.hazardousQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.reeferQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="blue" size="sm">
|
||||
{c.reeferQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
HandCoins,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -70,6 +71,18 @@ export const buildSidebarSections = (
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Lines",
|
||||
href: "/dashboard/shipping-lines",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.shippingLines.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Line Credits",
|
||||
href: "/dashboard/shipping-line-credits",
|
||||
icon: <HandCoins />,
|
||||
permission: FREIGHT_PERMS.shippingLineCredits.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
|
||||
@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
|
||||
current[name] ? { ...current, [name]: "" } : current,
|
||||
);
|
||||
setValues((current) => {
|
||||
// Mantine fires onChange even when the same option is re-picked, and the
|
||||
// cascades below clear dependent answers (yards, unit, scope). Re-picking
|
||||
// an unchanged value must be a no-op, or an untouched direction silently
|
||||
// wipes the yard pair and the submit fails with "origin/destination
|
||||
// missing" data the admin did fill in.
|
||||
if (current[name] === value) return current;
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
@@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
next.shippingLineCompanyId = "";
|
||||
next.shippingLineRateKind = "";
|
||||
next.shippingLineCargoKind = "";
|
||||
next.appliesTo = "";
|
||||
next.trigger = "";
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
|
||||
// the legal units for a shipping-line rate, exactly as appliesTo and
|
||||
// cargoKind do on the customer form.
|
||||
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
if (name === "shippingLineRateKind") {
|
||||
next.shippingLineCargoKind = "";
|
||||
next.trigger = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{field.description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{field.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
@@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
const computedSelect = field.computeValue
|
||||
? String(field.computeValue(values) ?? "")
|
||||
: undefined;
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -501,9 +552,18 @@ const RuleEngineFormDialog = ({
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computedSelect !== undefined
|
||||
}
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Ban, Check, HandCoins, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatMoney } from "@/components/customers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreditInvoiceActionType,
|
||||
CreditInvoicePendingAction,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
/** The slice of an invoice row the actions need — both list pages have it. */
|
||||
export interface CreditInvoiceActionTarget {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
}
|
||||
|
||||
/** Statuses an offline payment can still be recorded against. */
|
||||
const MARK_PAID_STATUSES = new Set([
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PAYMENT_PROCESSING",
|
||||
"PARTIALLY_PAID",
|
||||
"OVERDUE",
|
||||
]);
|
||||
|
||||
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
|
||||
MARK_PAID: "Mark paid",
|
||||
CANCEL: "Cancel invoice",
|
||||
};
|
||||
|
||||
export interface CreditInvoiceActionsProps {
|
||||
invoice: CreditInvoiceActionTarget;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
|
||||
* invoice list. Gated purely by permission: the request grants raise
|
||||
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
|
||||
* the holder's own included. Renders only the buttons the signed-in user's
|
||||
* grants allow; the API enforces the same gates server-side.
|
||||
*/
|
||||
export default function CreditInvoiceActions({
|
||||
invoice,
|
||||
pendingAction,
|
||||
}: CreditInvoiceActionsProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const canRequestPaid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
);
|
||||
const canRequestCancel = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
);
|
||||
const canApprove = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
);
|
||||
const canReject = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
);
|
||||
|
||||
const [requestAction, setRequestActionModal] =
|
||||
useState<CreditInvoiceActionType | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [paymentReference, setPaymentReference] = useState("");
|
||||
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
|
||||
const [decisionNote, setDecisionNote] = useState("");
|
||||
|
||||
const closeRequest = () => {
|
||||
setRequestActionModal(null);
|
||||
setReason("");
|
||||
setPaymentReference("");
|
||||
};
|
||||
const closeDecide = () => {
|
||||
setDecideApprove(null);
|
||||
setDecisionNote("");
|
||||
};
|
||||
|
||||
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
|
||||
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeRequest();
|
||||
toast({
|
||||
title: "Request submitted",
|
||||
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not submit request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
|
||||
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeDecide();
|
||||
toast({
|
||||
title: variables.approve ? "Request approved" : "Request rejected",
|
||||
description: variables.approve
|
||||
? pendingAction?.action === "MARK_PAID"
|
||||
? "The offline payment was recorded; the invoice and its credits are now paid."
|
||||
: "The invoice was cancelled; its credits returned to the unbilled pool."
|
||||
: "The request was rejected and nothing was changed.",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not decide request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let body = null;
|
||||
if (pendingAction) {
|
||||
body = (
|
||||
<Stack gap={6} py={4}>
|
||||
<Badge variant="light" color="orange" title={pendingAction.reason}>
|
||||
{ACTION_LABEL[pendingAction.action]} — awaiting approval
|
||||
</Badge>
|
||||
{canApprove || canReject ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canApprove ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={12} />}
|
||||
onClick={() => setDecideApprove(true)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<X size={12} />}
|
||||
onClick={() => setDecideApprove(false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
const showMarkPaid =
|
||||
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
|
||||
const showCancel =
|
||||
canRequestCancel &&
|
||||
invoice.status !== "CANCELLED" &&
|
||||
invoice.status !== "PAID" &&
|
||||
invoice.status !== "REFUNDED" &&
|
||||
Number(invoice.paidAmount) === 0;
|
||||
body =
|
||||
!showMarkPaid && !showCancel ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{showMarkPaid ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<HandCoins size={12} />}
|
||||
onClick={() => setRequestActionModal("MARK_PAID")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
) : null}
|
||||
{showCancel ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Ban size={12} />}
|
||||
onClick={() => setRequestActionModal("CANCEL")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
|
||||
{/* Maker: raise the request. */}
|
||||
<Modal
|
||||
opened={requestAction !== null}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
requestAction
|
||||
? `${ACTION_LABEL[requestAction]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{requestAction === "MARK_PAID"
|
||||
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
|
||||
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
|
||||
</Text>
|
||||
{requestAction === "MARK_PAID" ? (
|
||||
<TextInput
|
||||
label="Payment reference"
|
||||
description="Bank slip / transfer number, if any."
|
||||
value={paymentReference}
|
||||
onChange={(e) => setPaymentReference(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
minRows={2}
|
||||
placeholder={
|
||||
requestAction === "MARK_PAID"
|
||||
? "Paid by bank transfer, slip #…"
|
||||
: "Raised in error / rebilling with corrections…"
|
||||
}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isRequesting}
|
||||
disabled={reason.trim().length < 3}
|
||||
onClick={() =>
|
||||
requestAction &&
|
||||
submitRequest({
|
||||
invoiceId: invoice.id,
|
||||
action: requestAction,
|
||||
reason: reason.trim(),
|
||||
paymentReference: paymentReference.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit for approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Checker: decide the request. */}
|
||||
<Modal
|
||||
opened={decideApprove !== null}
|
||||
onClose={closeDecide}
|
||||
title={
|
||||
pendingAction
|
||||
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{pendingAction ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Requested reason:{" "}
|
||||
</Text>
|
||||
{pendingAction.reason}
|
||||
</Text>
|
||||
{pendingAction.paymentReference ? (
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Payment reference:{" "}
|
||||
</Text>
|
||||
{pendingAction.paymentReference}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
{decideApprove && pendingAction ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingAction.action === "MARK_PAID"
|
||||
? `Approving records ${formatMoney(
|
||||
Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)} as paid offline and settles the invoice's credits.`
|
||||
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
|
||||
</Text>
|
||||
) : null}
|
||||
<Textarea
|
||||
label={decideApprove ? "Note (optional)" : "Rejection note"}
|
||||
withAsterisk={!decideApprove}
|
||||
minRows={2}
|
||||
value={decisionNote}
|
||||
onChange={(e) => setDecisionNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeDecide}
|
||||
disabled={isDeciding}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color={decideApprove ? "edr-green" : "red"}
|
||||
loading={isDeciding}
|
||||
disabled={!decideApprove && !decisionNote.trim()}
|
||||
onClick={() =>
|
||||
pendingAction &&
|
||||
decideApprove !== null &&
|
||||
submitDecision({
|
||||
approvalId: pendingAction.id,
|
||||
approve: decideApprove,
|
||||
note: decisionNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{decideApprove ? "Approve & execute" : "Reject request"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ResetChannel,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* The carrier integration is domestic-only: anything else is queued and
|
||||
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||
*/
|
||||
function isDomesticPhone(rawPhone: string): boolean {
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||
? `+251${digits.replace(/^0+/, "")}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized);
|
||||
}
|
||||
|
||||
export interface ResendActivationActionProps {
|
||||
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a shipping line's activation link.
|
||||
*
|
||||
* The same single-use link registration sends: the carrier opens it and picks
|
||||
* their own password, so no credential is ever shown to or handled by staff.
|
||||
* Needed whenever the original send failed, expired (24h), or never arrived.
|
||||
*/
|
||||
export default function ResendActivationAction({
|
||||
shippingLine,
|
||||
}: ResendActivationActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send activation link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
const phoneUsable =
|
||||
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||
const channelMissing = channel === "phone" && !phoneUsable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Resend activation link"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to {shippingLine.name}. They choose
|
||||
their own password — you will not see it. The link expires in 24
|
||||
hours, and sending a new one invalidates nothing they haven't
|
||||
already used.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label="Send the link via"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={shippingLine.email}
|
||||
/>
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!shippingLine.phoneNumber
|
||||
? "No phone number on this account"
|
||||
: !phoneUsable
|
||||
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||
: shippingLine.phoneNumber
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{channelMissing ? (
|
||||
<Alert color="yellow" variant="light" p="sm">
|
||||
<Text size="sm">
|
||||
This account has no number the SMS gateway can reach. Send the
|
||||
link by email instead.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||
>
|
||||
Send activation link
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -28,6 +28,48 @@ export const QUERY_KEYS = {
|
||||
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
ROOT: ["shipping-line-companies"] as const,
|
||||
list: (page: number, limit: number) =>
|
||||
["shipping-line-companies", "list", page, limit] as const,
|
||||
byId: (id: string) =>
|
||||
["shipping-line-companies", "detail", id] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
ROOT: ["shipping-line-credits"] as const,
|
||||
invoices: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"invoices",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
summary: (shippingLineId?: string) =>
|
||||
["shipping-line-credits", "summary", shippingLineId ?? "all"] as const,
|
||||
list: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
status?: string,
|
||||
shippingLineId?: string,
|
||||
) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"list",
|
||||
shippingLineId ?? "all",
|
||||
page,
|
||||
pageSize,
|
||||
status ?? "all",
|
||||
] as const,
|
||||
},
|
||||
|
||||
CUSTOMERS: {
|
||||
ROOT: ["customers"] as const,
|
||||
stats: ["customers", "stats"] as const,
|
||||
|
||||
@@ -77,6 +77,33 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
/**
|
||||
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
|
||||
* rule-engine's pricing lookup list (a code/label bookings reference).
|
||||
*/
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
BASE: "/shipping-line-companies",
|
||||
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
|
||||
RESEND_ACTIVATION: (id: string) =>
|
||||
`/shipping-line-companies/${id}/resend-activation`,
|
||||
},
|
||||
|
||||
/** Finance's view of what shipping lines owe (use now, pay later). */
|
||||
SHIPPING_LINE_CREDITS: {
|
||||
BASE: "/shipping-line-credits",
|
||||
SUMMARY: "/shipping-line-credits/summary",
|
||||
INVOICE: "/shipping-line-credits/invoice",
|
||||
INVOICES: "/shipping-line-credits/invoices",
|
||||
MARK_PAID_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/mark-paid-request`,
|
||||
CANCEL_REQUEST: (invoiceId: string) =>
|
||||
`/shipping-line-credits/invoices/${invoiceId}/cancel-request`,
|
||||
APPROVE_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/approve`,
|
||||
REJECT_ACTION: (approvalId: string) =>
|
||||
`/shipping-line-credits/invoice-actions/${approvalId}/reject`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
STATS: "/companies/stats",
|
||||
|
||||
@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { api } from "@/services/api";
|
||||
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
|
||||
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping lines a rate can be scoped to. Only ACTIVE lines are offered — the
|
||||
* API refuses a rate filed against a suspended one, so listing them would only
|
||||
* produce an error on submit. Sorted by name so the picker is scannable.
|
||||
*/
|
||||
export const useShippingLineCompanyOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("shipping-line-companies", {}),
|
||||
// One page well past the number of carriers on the corridor; the picker
|
||||
// needs the whole list, not a page of it.
|
||||
queryFn: () => shippingLineCompaniesService.list(1, 200),
|
||||
enabled,
|
||||
select: (page: PaginatedShippingLineCompanies) =>
|
||||
page.items
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({
|
||||
label: line.scacCode ? `${line.name} (${line.scacCode})` : line.name,
|
||||
value: line.id,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
|
||||
@@ -123,6 +123,21 @@ export const FREIGHT_PERMS = {
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
update: "edr_freight_app:shipping_lines:update",
|
||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||
},
|
||||
shippingLineCredits: {
|
||||
view: "edr_freight_app:shipping_line_credits:view",
|
||||
invoice: "edr_freight_app:shipping_line_credits:invoice",
|
||||
cancel: "edr_freight_app:shipping_line_credits:cancel",
|
||||
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
|
||||
@@ -138,9 +138,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
// Querying a document is only possible while the booking is actually in
|
||||
// review — the server enforces exactly that (reviewDocument asserts
|
||||
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
|
||||
// only ever produce a 400.
|
||||
//
|
||||
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
|
||||
// so a non-customs booking (self-clearance, and every shipping-line booking)
|
||||
// never sets it and kept offering Query after Operations had finalized.
|
||||
const queriesLocked =
|
||||
Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)
|
||||
?.preClearanceFinalized,
|
||||
) ||
|
||||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
// Shipping-line credit invoices carry maker–checker actions (mark paid /
|
||||
// cancel). One batched lookup fetches the visible rows' pending requests.
|
||||
const creditInvoiceIds = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((inv) => inv.source === "shipping_line_credit")
|
||||
.map((inv) => inv.id),
|
||||
[rows],
|
||||
);
|
||||
const { data: pendingActions } = useQuery(
|
||||
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
|
||||
input: { invoiceIds: creditInvoiceIds },
|
||||
enabled: creditInvoiceIds.length > 0,
|
||||
}),
|
||||
);
|
||||
const pendingByInvoice = useMemo(
|
||||
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
|
||||
[pendingActions],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
// Only shipping-line credit invoices have manual maker–checker
|
||||
// actions; every other source settles through its own flow.
|
||||
if (inv.source !== "shipping_line_credit") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CreditInvoiceActions
|
||||
invoice={inv}
|
||||
pendingAction={pendingByInvoice.get(inv.id) ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[pendingByInvoice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useShippingLineCompanyOptions,
|
||||
useWagonTypeOptions,
|
||||
useYardOptions,
|
||||
type YardOption,
|
||||
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
|
||||
values: Record<string, unknown>,
|
||||
end: "origin" | "destination",
|
||||
): { label: string; value: string }[] => {
|
||||
// A shipping-line rate names its shape in its own fields and is always
|
||||
// import; map it onto the appliesTo/direction pair the rest of this function
|
||||
// reads so the country narrowing is shared rather than duplicated.
|
||||
if (values.isShippingLineRate === true) {
|
||||
if (!values.shippingLineCompanyId) return [];
|
||||
const isBase = values.shippingLineRateKind === "BASE";
|
||||
values = {
|
||||
...values,
|
||||
appliesTo: isBase
|
||||
? String(values.shippingLineCargoKind ?? "")
|
||||
: "OTHER",
|
||||
tradeDirection: "IMPORT",
|
||||
};
|
||||
}
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(false, usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const usesShippingLineField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
||||
);
|
||||
const {
|
||||
data: shippingLineOptions,
|
||||
isLoading: shippingLineOptionsLoading,
|
||||
} = useShippingLineCompanyOptions(usesShippingLineField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
const usesYardField = Boolean(
|
||||
@@ -390,6 +412,13 @@ const RuleEngineResourcePage = () => {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (field.name === "shippingLineCompanyId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: shippingLineOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
let payload = values;
|
||||
if (config.slug === "rates") {
|
||||
if (config.slug === "rates" && values.isShippingLineRate === true) {
|
||||
// A shipping-line rate asks its shape as "base freight vs surcharge" +
|
||||
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
|
||||
// customer rate, so translate here and drop the form-only fields. Always
|
||||
// import (the only direction a line ships) and always USD.
|
||||
const {
|
||||
isShippingLineRate: _toggle,
|
||||
shippingLineRateKind,
|
||||
shippingLineCargoKind,
|
||||
...rest
|
||||
} = values;
|
||||
void _toggle;
|
||||
const isBase = shippingLineRateKind === "BASE";
|
||||
payload = {
|
||||
...rest,
|
||||
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
|
||||
trigger: isBase ? "ALWAYS" : values.trigger,
|
||||
tradeDirection: "IMPORT",
|
||||
currency: "USD",
|
||||
};
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (config.slug === "rates") {
|
||||
// Base-freight categories have no surcharge trigger field — the engine
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
@@ -621,7 +682,11 @@ const RuleEngineResourcePage = () => {
|
||||
// ton·km, container = per km + distance band) and the currency stays as
|
||||
// chosen (birr or dollar). Everything else remains USD-only.
|
||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||
const { lastMileMode, ...rest } = values;
|
||||
// The shipping-line toggle is form-only — the API's whitelist rejects the
|
||||
// whole payload if it leaks through ("property isShippingLineRate should
|
||||
// not exist").
|
||||
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
|
||||
void _toggle;
|
||||
payload = {
|
||||
...rest,
|
||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||
@@ -934,6 +999,7 @@ const RuleEngineResourcePage = () => {
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesShippingLineField && shippingLineOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
|
||||
@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
filters: {
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* "true" = only shipping-line rates, "false" = only standard customer
|
||||
* rates. Sent as a string because tab filters go on the query string
|
||||
* verbatim.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A shipping-line rate: priced for one carrier's own bookings instead of for
|
||||
* every customer. The toggle drives the whole form — until a line is picked
|
||||
* there is nothing to configure, and the shape questions (base freight vs
|
||||
* surcharge, container vs bulk) are asked only after it is.
|
||||
*/
|
||||
const isShippingLineRate = (values: Record<string, unknown>) =>
|
||||
values.isShippingLineRate === true;
|
||||
|
||||
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
|
||||
const hasShippingLine = (values: Record<string, unknown>) =>
|
||||
isShippingLineRate(values) && Boolean(values.shippingLineCompanyId);
|
||||
|
||||
/**
|
||||
* What a shipping-line rate prices. Deliberately narrower than the customer
|
||||
* form's `appliesTo`: a line buys base rail freight (its own containers or
|
||||
* bulk) or a surcharge, and nothing else — intercity and first/last mile are
|
||||
* customer products.
|
||||
*/
|
||||
const SHIPPING_LINE_RATE_KINDS = [
|
||||
{ label: "Base freight", value: "BASE" },
|
||||
{ label: "Surcharge", value: "SURCHARGE" },
|
||||
];
|
||||
|
||||
/** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */
|
||||
const SHIPPING_LINE_CARGO_KINDS = [
|
||||
{ label: "Container", value: "CONTAINER" },
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
@@ -214,7 +253,15 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
// A shipping line's base freight is priced per leg exactly like a customer's;
|
||||
// its surcharges are route-scoped on the same triggers.
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
|
||||
values: Record<string, unknown>,
|
||||
cargoUnitOfMeasure = "",
|
||||
) => {
|
||||
// A shipping-line rate answers the same two questions under different names —
|
||||
// map them onto the shape the unit table is keyed by. Base freight for a line
|
||||
// is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger.
|
||||
if (isShippingLineRate(values)) {
|
||||
const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values;
|
||||
if (kind === "BASE") {
|
||||
if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return [];
|
||||
return allowedRateUnits(
|
||||
String(cargoKind),
|
||||
"ALWAYS",
|
||||
"",
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
if (kind === "SURCHARGE" && values.trigger) {
|
||||
return allowedRateUnits(
|
||||
"OTHER",
|
||||
String(values.trigger),
|
||||
String(values.cargoKind ?? ""),
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
// "All" and every shape tab show customer rates only — a shipping line's
|
||||
// negotiated price is its own list, not an extra row in the standard one.
|
||||
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "shipping-line",
|
||||
label: "Shipping line",
|
||||
filters: { isShippingLineRate: "true" },
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
isShippingLineRate: "false",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
// Blank on a standard customer rate; the owning carrier on a line rate.
|
||||
{
|
||||
id: "shippingLineCompany",
|
||||
header: "Shipping line",
|
||||
accessorKey: "shippingLineCompany",
|
||||
format: "entityLabel",
|
||||
},
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
// Base freight is priced per leg, so the route is what tells two otherwise
|
||||
@@ -879,6 +966,59 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
// ── Shipping line rate ────────────────────────────────────────────────
|
||||
// Flipping this on replaces the whole customer form: the only question
|
||||
// is which line, and the shape questions follow once it is answered.
|
||||
{
|
||||
name: "isShippingLineRate",
|
||||
label: "Shipping line rate",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.",
|
||||
// The owner is part of a rate's identity, so switching an existing rate
|
||||
// between customer and line pricing would silently re-target every
|
||||
// booking that prices off it. Create a new rate instead.
|
||||
disabledOnEdit: true,
|
||||
getInitialValue: (record) => Boolean(record.shippingLineCompanyId),
|
||||
},
|
||||
{
|
||||
name: "shippingLineCompanyId",
|
||||
label: "Shipping line",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which shipping line this rate is for",
|
||||
description:
|
||||
"Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.",
|
||||
disabledOnEdit: true,
|
||||
showIf: isShippingLineRate,
|
||||
},
|
||||
// What the line is buying. Asked only after a line is picked, so the form
|
||||
// stays a single question until then.
|
||||
{
|
||||
name: "shippingLineRateKind",
|
||||
label: "Rate type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_RATE_KINDS,
|
||||
placeholder: "Base freight or a surcharge?",
|
||||
showIf: hasShippingLine,
|
||||
// Not stored: base freight carries trigger ALWAYS, a surcharge anything else.
|
||||
getInitialValue: (record) =>
|
||||
!record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE",
|
||||
},
|
||||
// Container vs bulk — the line form asks this directly instead of folding
|
||||
// it into `appliesTo` the way the customer form does.
|
||||
{
|
||||
name: "shippingLineCargoKind",
|
||||
label: "Cargo kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_CARGO_KINDS,
|
||||
placeholder: "Is this rate for containers or bulk?",
|
||||
showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE",
|
||||
getInitialValue: (record) =>
|
||||
record.appliesTo === "BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "appliesTo",
|
||||
label: "Applies to",
|
||||
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_APPLIES_TO,
|
||||
description:
|
||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||
// Derived from the two questions above on a shipping-line rate.
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||
{
|
||||
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// The same trigger list for a shipping-line surcharge — a line incurs the
|
||||
// same charges a customer does (hazard, reefer, demurrage …), just at its
|
||||
// own negotiated price.
|
||||
{
|
||||
name: "trigger",
|
||||
label: "Surcharge trigger",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
)),
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
// as a locked field rather than hidden so the lane the yard pickers are
|
||||
// filtered by is visible.
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ label: "Import", value: "IMPORT" }],
|
||||
description: "Shipping line rates are import-only.",
|
||||
disabled: true,
|
||||
// No defaultValue: field names repeat across form variants and the
|
||||
// seeded initial value is shared, so defaulting here would pre-select
|
||||
// Import on the customer form's own direction field too. computeValue
|
||||
// pins IMPORT on submit and locks the input regardless.
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")),
|
||||
},
|
||||
// Bulk commodity for a shipping-line base-freight rate. Its unit of
|
||||
// measure decides the rate unit offered below — a counted commodity
|
||||
// (PER_ITEM) prices per item where a weighed one prices per ton.
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which bulk commodity this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "BULK",
|
||||
},
|
||||
// ── The leg this rate prices — base freight only ──────────────────────
|
||||
// Options are narrowed to the countries the direction allows (import
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** SCAC is 2-4 letters; the API enforces the same rule. */
|
||||
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
scacCode: string;
|
||||
imoNumber: string;
|
||||
bicCode: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormValues = {
|
||||
name: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
scacCode: "",
|
||||
imoNumber: "",
|
||||
bicCode: "",
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping line companies — carriers with their own portal login.
|
||||
*
|
||||
* Registration is staff-only: there is no self-signup. Staff never set a
|
||||
* password; the system emails (and texts, when the number is domestic) a
|
||||
* single-use activation link that the carrier uses to choose their own.
|
||||
*/
|
||||
export default function ShippingLineCompaniesPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
limit: pagination.pageSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const setField = (field: keyof FormValues) => (value: string) =>
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Mirrors the API's own validation, so the obvious mistakes are caught before
|
||||
// a round trip. The server still enforces all of it.
|
||||
const errors = {
|
||||
name: values.name.trim() ? null : "Company name is required",
|
||||
// Required, unlike a customer's: the activation link is sent here, so an
|
||||
// account without one could never be signed in to.
|
||||
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
|
||||
? null
|
||||
: "A valid email is required",
|
||||
scacCode:
|
||||
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
|
||||
? null
|
||||
: "SCAC must be 2-4 letters",
|
||||
};
|
||||
const isValid = !errors.name && !errors.email && !errors.scacCode;
|
||||
|
||||
const closeRegister = () => {
|
||||
setRegisterOpen(false);
|
||||
setValues(EMPTY_FORM);
|
||||
setTouched(false);
|
||||
};
|
||||
|
||||
const { mutate: register, isPending: isRegistering } = useMutation(
|
||||
api.shippingLineCompanies.register.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
closeRegister();
|
||||
toast({
|
||||
title: "Shipping line registered",
|
||||
description: result.activationSentTo
|
||||
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||
: // The account exists and is valid — only delivery failed, and the
|
||||
// link can be resent, so this is a warning rather than an error.
|
||||
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
|
||||
variant: result.activationSentTo ? undefined : "destructive",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not register shipping line",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Shipping line",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
<Ship size={18} strokeWidth={1.9} />
|
||||
</Box>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
{sl.name}
|
||||
</Text>
|
||||
{sl.scacCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
SCAC {sl.scacCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Mail size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" truncate>
|
||||
{sl.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{sl.phoneNumber ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Phone size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{sl.phoneNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "identifiers",
|
||||
header: "Identifiers",
|
||||
cell: ({ row }) => {
|
||||
const { imoNumber, bicCode } = row.original;
|
||||
if (!imoNumber && !bicCode) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
|
||||
{bicCode ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
BIC {bicCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={row.original.status === "active" ? "green" : "red"}
|
||||
>
|
||||
{row.original.status === "active" ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Registered",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ResendActivationAction shippingLine={row.original} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Lines"
|
||||
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
>
|
||||
Register shipping line
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No shipping lines registered yet."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load shipping lines.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={registerOpen}
|
||||
onClose={closeRegister}
|
||||
title="Register shipping line"
|
||||
centered
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
if (!isValid) return;
|
||||
register({
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
phoneNumber: values.phoneNumber.trim() || undefined,
|
||||
scacCode: values.scacCode.trim() || undefined,
|
||||
imoNumber: values.imoNumber.trim() || undefined,
|
||||
bicCode: values.bicCode.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
No password is set here. The shipping line receives a single-use
|
||||
activation link and chooses their own.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
placeholder="Ethiopian Shipping Lines"
|
||||
withAsterisk
|
||||
value={values.name}
|
||||
onChange={(e) => setField("name")(e.currentTarget.value)}
|
||||
error={touched ? errors.name : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="ops@example.com"
|
||||
description="The activation link is sent here."
|
||||
withAsterisk
|
||||
value={values.email}
|
||||
onChange={(e) => setField("email")(e.currentTarget.value)}
|
||||
error={touched ? errors.email : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251911223344"
|
||||
description="Ethiopian numbers also receive the link by SMS."
|
||||
value={values.phoneNumber}
|
||||
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="SCAC"
|
||||
placeholder="ESLK"
|
||||
value={values.scacCode}
|
||||
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
|
||||
error={touched ? errors.scacCode : null}
|
||||
/>
|
||||
<TextInput
|
||||
label="IMO number"
|
||||
placeholder="IMO9074729"
|
||||
value={values.imoNumber}
|
||||
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="BIC code"
|
||||
placeholder="ESLU"
|
||||
value={values.bicCode}
|
||||
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isRegistering}>
|
||||
Register & send link
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreditInvoice } from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
ISSUED: { label: "Issued", color: "orange" },
|
||||
PENDING: { label: "Pending", color: "orange" },
|
||||
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
|
||||
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
OVERDUE: { label: "Overdue", color: "red" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
REFUNDED: { label: "Refunded", color: "blue" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
|
||||
([value, meta]) => ({ value, label: meta.label }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Invoices minted from credit batches. The actions column is the shared
|
||||
* maker–checker component (also embedded on the Finance hub's invoice list):
|
||||
* finance requests mark-paid / cancel, a chief approves or rejects.
|
||||
*/
|
||||
export default function ShippingLineCreditInvoicesPanel() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
|
||||
api.shippingLineCredits.listInvoices.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const columns: ColumnDef<CreditInvoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm font-semibold text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{inv.shippingLineName ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
header: () => <span className={bookingTable.headerCell}>Issued</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "due",
|
||||
header: () => <span className={bookingTable.headerCell}>Due</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
header: () => <span className={bookingTable.headerCell}>Balance</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMoney(
|
||||
Number(row.original.balanceAmount ?? row.original.totalAmount),
|
||||
row.original.currency,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = INVOICE_STATUS_META[row.original.status] ?? {
|
||||
label: row.original.status,
|
||||
color: "gray",
|
||||
};
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<CreditInvoiceActions
|
||||
invoice={row.original}
|
||||
pendingAction={row.original.pendingAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={() => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credit invoices yet — generate one from the Credits tab."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Stack, Tabs } from "@mantine/core";
|
||||
import { HandCoins, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
|
||||
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
|
||||
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
|
||||
|
||||
/**
|
||||
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
|
||||
* FinanceHubPage convention): the credit ledger (select unbilled credits →
|
||||
* generate an invoice) and the invoices minted from it (maker–checker
|
||||
* mark-paid / cancel actions).
|
||||
*/
|
||||
export default function ShippingLineCreditsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab =
|
||||
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Line Credits"
|
||||
subtitle={
|
||||
activeTab === "invoices"
|
||||
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
|
||||
: "What each line owes — outstanding totals and the full credit ledger."
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
|
||||
Credits
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="credits" pt="lg">
|
||||
<ShippingLineCreditsPanel />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="invoices" pt="lg">
|
||||
<ShippingLineCreditInvoicesPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
FilterX,
|
||||
HandCoins,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { formatDate, formatMoney } from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ShippingLineCredit,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_META: Record<
|
||||
ShippingLineCreditStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
UNBILLED: { label: "Unbilled", color: "orange" },
|
||||
BILLED: { label: "Billed", color: "blue" },
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Every shipping line's credits in one list — finance's landing view, styled
|
||||
* to match the booking-requests page. Summary cells total the current filter
|
||||
* scope (all lines by default); the selects narrow both cells and ledger.
|
||||
*/
|
||||
export default function ShippingLineCreditsPanel() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const canInvoice = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoice,
|
||||
);
|
||||
|
||||
// Selection for batch invoicing, kept as id → credit so it survives page
|
||||
// changes and can total itself. One invoice has one payer, so everything
|
||||
// selected must belong to the same shipping line — enforced here so the
|
||||
// API's rejection is never the first time staff hears about it.
|
||||
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
|
||||
new Map(),
|
||||
);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [dueInDays, setDueInDays] = useState<number | "">("");
|
||||
|
||||
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
|
||||
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
|
||||
const selectedTotal = selectedCredits.reduce(
|
||||
(sum, c) => sum + Number(c.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
const toggleSelected = (credit: ShippingLineCredit) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(credit.id)) next.delete(credit.id);
|
||||
else next.set(credit.id, credit);
|
||||
return next;
|
||||
});
|
||||
|
||||
const clearSelection = () => setSelected(new Map());
|
||||
|
||||
// ponytail: first 100 lines in the picker; server-side search when a real
|
||||
// deployment outgrows that.
|
||||
const { data: companies } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
}),
|
||||
);
|
||||
|
||||
const lineOptions = useMemo(
|
||||
() =>
|
||||
(companies?.items ?? []).map((sl) => ({
|
||||
value: sl.id,
|
||||
label: sl.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
|
||||
})),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.summary.queryOptions({
|
||||
input: { shippingLineId: shippingLineId ?? undefined },
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
data: ledger,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
api.shippingLineCredits.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
status: status ?? undefined,
|
||||
shippingLineId: shippingLineId ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = ledger?.items ?? [];
|
||||
const total = ledger?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const clearFilters = () => {
|
||||
setShippingLineId(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
};
|
||||
|
||||
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
|
||||
api.shippingLineCredits.generateInvoice.mutationOptions({
|
||||
onSuccess: (invoice) => {
|
||||
setInvoiceOpen(false);
|
||||
clearSelection();
|
||||
setDueInDays("");
|
||||
toast({
|
||||
title: `Invoice ${invoice.invoiceNumber} generated`,
|
||||
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not generate invoice",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
// A concurrent edit (someone else billed a selected credit) is the
|
||||
// usual cause — resync so stale rows drop out of the list.
|
||||
handleRefresh();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
|
||||
() => [
|
||||
...(canInvoice
|
||||
? [
|
||||
{
|
||||
id: "select",
|
||||
size: 40,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
|
||||
const credit = row.original;
|
||||
const selectable =
|
||||
credit.status === "UNBILLED" &&
|
||||
(selectedLineId === null ||
|
||||
credit.shippingLineCompanyId === selectedLineId);
|
||||
return (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected.has(credit.id)}
|
||||
disabled={!selectable}
|
||||
title={
|
||||
credit.status !== "UNBILLED"
|
||||
? "Only unbilled credits can be invoiced"
|
||||
: !selectable
|
||||
? "One invoice has one payer — selection already holds another line's credits"
|
||||
: undefined
|
||||
}
|
||||
onChange={() => toggleSelected(credit)}
|
||||
aria-label="Select credit for invoicing"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "shippingLine",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Shipping line</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const credit = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{credit.shippingLineCompany?.name ?? "—"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{credit.booking?.reference ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Description</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
|
||||
{row.original.description ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatMoney(Number(row.original.amount), row.original.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original.invoice;
|
||||
return inv ? (
|
||||
<span className="truncate font-mono text-xs text-foreground">
|
||||
{inv.invoiceNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
// Selection state drives the checkbox column's checked/disabled rendering.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[canInvoice, selected, selectedLineId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Total outstanding",
|
||||
value: summary
|
||||
? formatMoney(summary.totalOutstanding, summary.currency)
|
||||
: "—",
|
||||
hint: "unbilled + billed",
|
||||
icon: HandCoins,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Unbilled",
|
||||
value: summary
|
||||
? formatMoney(summary.unbilledAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.unbilledCount} credits` : undefined,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Billed",
|
||||
value: summary
|
||||
? formatMoney(summary.billedAmount, summary.currency)
|
||||
: "—",
|
||||
hint: summary ? `${summary.billedCount} on invoices` : undefined,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All shipping lines"
|
||||
data={lineOptions}
|
||||
value={shippingLineId}
|
||||
onChange={(v) => {
|
||||
setShippingLineId(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as ShippingLineCreditStatus | null) ?? null);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{selectedCredits.length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Group
|
||||
px="md"
|
||||
py="sm"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
bg="var(--mantine-color-edr-green-0)"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{selectedCredits.length} credit
|
||||
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
|
||||
{formatMoney(selectedTotal, selectedCredits[0].currency)}
|
||||
{" — "}
|
||||
{selectedCredits[0].shippingLineCompany?.name ?? ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
leftSection={<Receipt size={14} />}
|
||||
onClick={() => setInvoiceOpen(true)}
|
||||
>
|
||||
Generate invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No credits match this filter."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load credits.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={invoiceOpen}
|
||||
onClose={() => setInvoiceOpen(false)}
|
||||
title="Generate invoice"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
One invoice for{" "}
|
||||
<Text component="span" fw={600} c="edr-text">
|
||||
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
|
||||
</Text>{" "}
|
||||
billing the selected credits. The line pays it at any CBE channel —
|
||||
there is no payment window.
|
||||
</Text>
|
||||
|
||||
<Stack gap={6}>
|
||||
{selectedCredits.map((credit) => (
|
||||
<Group key={credit.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" truncate>
|
||||
{credit.booking?.reference ?? credit.description ?? credit.id}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{formatMoney(Number(credit.amount), credit.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(
|
||||
selectedTotal,
|
||||
selectedCredits[0]?.currency ?? "ETB",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<NumberInput
|
||||
label="Due in days"
|
||||
description="Optional — defaults to the standard invoice term."
|
||||
placeholder="14"
|
||||
min={1}
|
||||
value={dueInDays}
|
||||
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setInvoiceOpen(false)}
|
||||
disabled={isInvoicing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isInvoicing}
|
||||
onClick={() =>
|
||||
generateInvoice({
|
||||
creditIds: selectedCredits.map((c) => c.id),
|
||||
...(typeof dueInDays === "number"
|
||||
? { dueInDays }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Generate & issue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
|
||||
// Booking window for the schedule being created: off = inherit the live global
|
||||
// rules (the default), on = the values in `windowForm` are frozen onto it.
|
||||
const [configureWindow, setConfigureWindow] = useState(false);
|
||||
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
enabled: Boolean(routeId),
|
||||
}),
|
||||
);
|
||||
// For the create modal's dedication picker. 100 covers every line EDR deals
|
||||
// with; fetched only while the modal is open.
|
||||
const shippingLinesQuery = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: { page: 1, limit: 100 },
|
||||
enabled: createOpen,
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const dispatchSchedule = useMutation(
|
||||
api.trainScheduling.dispatchSchedule.mutationOptions(),
|
||||
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
setWindowForm(null);
|
||||
setCreateOpen(false);
|
||||
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
placeholder="None — normal customer train"
|
||||
clearable
|
||||
searchable
|
||||
data={(shippingLinesQuery.data?.items ?? [])
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({ value: line.id, label: line.name }))}
|
||||
value={shippingLineCompanyId || null}
|
||||
onChange={(v) => setShippingLineCompanyId(v ?? "")}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Reverse wagon order"
|
||||
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."
|
||||
|
||||
@@ -44,6 +44,20 @@ import type {
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
import {
|
||||
RuleEngineListResult,
|
||||
RuleEngineRecord,
|
||||
@@ -156,6 +170,8 @@ import {
|
||||
import { containerTypesService } from "./container-types.service";
|
||||
import { containerService, type Container } from "./containerService";
|
||||
import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
@@ -2792,6 +2808,160 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCompanies: {
|
||||
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
|
||||
"shippingLineCompanies",
|
||||
"list",
|
||||
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
|
||||
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, ShippingLineCompany>(
|
||||
"shippingLineCompanies",
|
||||
"getById",
|
||||
({ id }) => shippingLineCompaniesService.getById(id),
|
||||
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
|
||||
),
|
||||
|
||||
register: endpoint<
|
||||
CreateShippingLineCompanyDto,
|
||||
RegisterShippingLineCompanyResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"register",
|
||||
(dto) => shippingLineCompaniesService.register(dto),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
|
||||
),
|
||||
|
||||
resendActivation: endpoint<
|
||||
{ id: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"resendActivation",
|
||||
({ id, channel }) =>
|
||||
shippingLineCompaniesService.resendActivation(id, channel),
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCredits: {
|
||||
summary: endpoint<{ shippingLineId?: string }, OutstandingTotals>(
|
||||
"shippingLineCredits",
|
||||
"summary",
|
||||
({ shippingLineId }) => shippingLineCreditsService.summary(shippingLineId),
|
||||
({ shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.summary(shippingLineId),
|
||||
),
|
||||
|
||||
list: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedShippingLineCredits
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"list",
|
||||
(filter) => shippingLineCreditsService.list(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.list(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
generateInvoice: endpoint<
|
||||
{ creditIds: string[]; dueInDays?: number },
|
||||
GeneratedCreditInvoice
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"generateInvoice",
|
||||
({ creditIds, dueInDays }) =>
|
||||
shippingLineCreditsService.generateInvoice(creditIds, dueInDays),
|
||||
undefined,
|
||||
// Billing a batch changes ledger rows, the summary totals and (via the
|
||||
// draft invoice) the invoices list.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
|
||||
listInvoices: endpoint<
|
||||
{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
},
|
||||
PaginatedCreditInvoices
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"listInvoices",
|
||||
(filter) => shippingLineCreditsService.listInvoices(filter),
|
||||
({ page, pageSize, status, shippingLineId }) =>
|
||||
QUERY_KEYS.SHIPPING_LINE_CREDITS.invoices(
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
shippingLineId,
|
||||
),
|
||||
),
|
||||
|
||||
pendingInvoiceActions: endpoint<
|
||||
{ invoiceIds: string[] },
|
||||
CreditInvoicePendingAction[]
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"pendingInvoiceActions",
|
||||
({ invoiceIds }) =>
|
||||
shippingLineCreditsService.pendingInvoiceActions(invoiceIds),
|
||||
({ invoiceIds }) =>
|
||||
[
|
||||
"shipping-line-credits",
|
||||
"pending-actions",
|
||||
[...invoiceIds].sort().join(","),
|
||||
] as const,
|
||||
),
|
||||
|
||||
requestInvoiceAction: endpoint<
|
||||
{
|
||||
invoiceId: string;
|
||||
action: "MARK_PAID" | "CANCEL";
|
||||
reason: string;
|
||||
paymentReference?: string;
|
||||
},
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"requestInvoiceAction",
|
||||
({ invoiceId, action, reason, paymentReference }) =>
|
||||
shippingLineCreditsService.requestInvoiceAction(
|
||||
invoiceId,
|
||||
action,
|
||||
reason,
|
||||
paymentReference,
|
||||
),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT],
|
||||
),
|
||||
|
||||
decideInvoiceAction: endpoint<
|
||||
{ approvalId: string; approve: boolean; note?: string },
|
||||
CreditInvoicePendingAction
|
||||
>(
|
||||
"shippingLineCredits",
|
||||
"decideInvoiceAction",
|
||||
({ approvalId, approve, note }) =>
|
||||
shippingLineCreditsService.decideInvoiceAction(approvalId, approve, note),
|
||||
undefined,
|
||||
// Approving executes a billing action, so both surfaces move.
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
},
|
||||
|
||||
customers: {
|
||||
stats: endpoint<Record<string, never>, CompanyStats>(
|
||||
"customers",
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* Rates only: "true" lists shipping-line rates, "false" standard customer
|
||||
* ones. Omitted lists both.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -214,6 +219,7 @@ export const ruleEngineService = {
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
isShippingLineRate: params?.isShippingLineRate,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
export const shippingLineCompaniesService = {
|
||||
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCompanies>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
{ params: { page, limit } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<ShippingLineCompany> {
|
||||
return apiClient
|
||||
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the carrier's account and record, then sends an activation link.
|
||||
* Staff never set or see a password — the line chooses its own from the link.
|
||||
*/
|
||||
register(
|
||||
dto: CreateShippingLineCompanyDto,
|
||||
): Promise<RegisterShippingLineCompanyResult> {
|
||||
return apiClient
|
||||
.post<RegisterShippingLineCompanyResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
dto,
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
resendActivation(
|
||||
id: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreditInvoicePendingAction,
|
||||
GeneratedCreditInvoice,
|
||||
OutstandingTotals,
|
||||
PaginatedCreditInvoices,
|
||||
PaginatedShippingLineCredits,
|
||||
ShippingLineCreditStatus,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
export interface ShippingLineCreditListFilter {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ShippingLineCreditStatus;
|
||||
/** Narrow to one line; omit for all lines. */
|
||||
shippingLineId?: string;
|
||||
}
|
||||
|
||||
export const shippingLineCreditsService = {
|
||||
/** Outstanding totals — every line, or one line when an id is given. */
|
||||
summary(shippingLineId?: string): Promise<OutstandingTotals> {
|
||||
return apiClient
|
||||
.get<OutstandingTotals>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.SUMMARY, {
|
||||
params: shippingLineId ? { shippingLineId } : {},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** The whole credit ledger, newest first, optionally filtered. */
|
||||
list(
|
||||
filter: ShippingLineCreditListFilter = {},
|
||||
): Promise<PaginatedShippingLineCredits> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCredits>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bill a batch of unbilled credits as one invoice. The API enforces that all
|
||||
* credits belong to one shipping line and share one currency.
|
||||
*/
|
||||
generateInvoice(
|
||||
creditIds: string[],
|
||||
dueInDays?: number,
|
||||
): Promise<GeneratedCreditInvoice> {
|
||||
return apiClient
|
||||
.post<GeneratedCreditInvoice>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICE, {
|
||||
creditIds,
|
||||
...(dueInDays ? { dueInDays } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Credit invoices with any pending manual-action request attached. */
|
||||
listInvoices(filter: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
shippingLineId?: string;
|
||||
} = {}): Promise<PaginatedCreditInvoices> {
|
||||
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
|
||||
return apiClient
|
||||
.get<PaginatedCreditInvoices>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICES, {
|
||||
params: {
|
||||
page,
|
||||
pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(shippingLineId ? { shippingLineId } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Undecided manual-action requests for a batch of invoice ids. */
|
||||
pendingInvoiceActions(
|
||||
invoiceIds: string[],
|
||||
): Promise<CreditInvoicePendingAction[]> {
|
||||
if (!invoiceIds.length) return Promise.resolve([]);
|
||||
return apiClient
|
||||
.get<CreditInvoicePendingAction[]>(
|
||||
`${URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE}/invoice-actions/pending`,
|
||||
{ params: { invoiceIds: invoiceIds.join(",") } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Maker step: raise a mark-paid or cancel request on a credit invoice. */
|
||||
requestInvoiceAction(
|
||||
invoiceId: string,
|
||||
action: "MARK_PAID" | "CANCEL",
|
||||
reason: string,
|
||||
paymentReference?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url =
|
||||
action === "MARK_PAID"
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.MARK_PAID_REQUEST(invoiceId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.CANCEL_REQUEST(invoiceId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, {
|
||||
reason,
|
||||
...(paymentReference ? { paymentReference } : {}),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Decision step: approve (executes) or reject a pending request. */
|
||||
decideInvoiceAction(
|
||||
approvalId: string,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<CreditInvoicePendingAction> {
|
||||
const url = approve
|
||||
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.APPROVE_ACTION(approvalId)
|
||||
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.REJECT_ACTION(approvalId);
|
||||
return apiClient
|
||||
.post<CreditInvoicePendingAction>(url, note ? { note } : {})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -90,6 +90,9 @@ export interface BookingContainerLine {
|
||||
containerNumber?: string | null;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
/** How many of this line are hazardous / refrigerated — 0 when none. */
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
containerType?: {
|
||||
id: string;
|
||||
code?: string;
|
||||
@@ -199,6 +202,7 @@ export interface BookingDetail {
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
isHazardous: boolean;
|
||||
isReefer?: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
priorityScore: number;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ResetChannel, ResetPasswordResult } from "./customer";
|
||||
|
||||
export type ShippingLineStatus = "active" | "suspended";
|
||||
|
||||
/**
|
||||
* A carrier with its own portal login, registered by staff.
|
||||
*
|
||||
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
|
||||
* that is a pricing lookup — a code/label a booking points at — with no account
|
||||
* and no login. This one is the account.
|
||||
*/
|
||||
export interface ShippingLineCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
imoNumber: string | null;
|
||||
bicCode: string | null;
|
||||
status: ShippingLineStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateShippingLineCompanyDto {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
scacCode?: string;
|
||||
imoNumber?: string;
|
||||
bicCode?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface RegisterShippingLineCompanyResult {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/**
|
||||
* Masked destination the activation link went to, or null when delivery
|
||||
* failed. The registration still succeeded — the link is resendable.
|
||||
*/
|
||||
activationSentTo: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCompanies {
|
||||
items: ShippingLineCompany[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type { ResetChannel, ResetPasswordResult };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The credit ledger for shipping lines — "use the service now, pay later".
|
||||
* Mirrors `shipping-line-credits` API responses.
|
||||
*/
|
||||
|
||||
export type ShippingLineCreditStatus =
|
||||
| "UNBILLED"
|
||||
| "BILLED"
|
||||
| "PAID"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface ShippingLineCredit {
|
||||
id: string;
|
||||
shippingLineCompanyId: string;
|
||||
bookingId: string;
|
||||
/** Numeric column — serialized as a string by the API. */
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: ShippingLineCreditStatus;
|
||||
description: string | null;
|
||||
invoiceId: string | null;
|
||||
billedAt: string | null;
|
||||
paidAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
cancellationReason: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string } | null;
|
||||
invoice?: { id: string; invoiceNumber: string } | null;
|
||||
shippingLineCompany?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** What one shipping line currently owes, split by billing stage. */
|
||||
export interface OutstandingTotals {
|
||||
unbilledAmount: number;
|
||||
billedAmount: number;
|
||||
totalOutstanding: number;
|
||||
unbilledCount: number;
|
||||
billedCount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCredits {
|
||||
items: ShippingLineCredit[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** The invoice minted from a batch of unbilled credits (subset of fields). */
|
||||
export interface GeneratedCreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
totalAmount: string | number;
|
||||
currency: string;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
export type CreditInvoiceActionType = "MARK_PAID" | "CANCEL";
|
||||
export type CreditInvoiceActionStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
/** An undecided manual-action request attached to a credit invoice. */
|
||||
export interface CreditInvoicePendingAction {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
action: CreditInvoiceActionType;
|
||||
status: CreditInvoiceActionStatus;
|
||||
requestedBy: string;
|
||||
reason: string;
|
||||
paymentReference: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A credit invoice row in the staff list, enriched by the API. */
|
||||
export interface CreditInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
shippingLineCompanyId: string | null;
|
||||
shippingLineName: string | null;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
export interface PaginatedCreditInvoices {
|
||||
items: CreditInvoice[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -960,6 +960,11 @@ export interface CreateTrainSchedulePayload {
|
||||
maxWagonsPerTrain?: number;
|
||||
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
||||
reverseWagonOrder?: boolean;
|
||||
/**
|
||||
* Dedicate this departure to one shipping line — hidden from customers,
|
||||
* visible only to that line in its portal. Omit for a normal customer train.
|
||||
*/
|
||||
shippingLineCompanyId?: string;
|
||||
/**
|
||||
* Configure the booking window for THIS schedule instead of inheriting the
|
||||
* live global rules. Omit to follow the global rules (the default).
|
||||
|
||||
Reference in New Issue
Block a user