mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
booking cancellation
This commit is contained in:
@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
}
|
||||
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||
// Cargo kind (customs / cancellation) decides both the container-type scope
|
||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||
if (name === "cargoKind") {
|
||||
next.containerTypeId = "";
|
||||
|
||||
@@ -95,6 +95,8 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "offline-usd", filter ?? {}] as const,
|
||||
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
|
||||
["invoices", "summary", filter ?? {}] as const,
|
||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||
},
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
BILLING: {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICES_SUMMARY: "/billing/invoices/summary",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Landmark, Receipt, Wallet } from "lucide-react";
|
||||
import { Landmark, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
import InvoicesPanel from "./InvoicesPage";
|
||||
import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||
import PaymentsPanel from "../payments/PaymentsPage";
|
||||
|
||||
/**
|
||||
* Invoices, Payments, and USD Payments used to be three separate routes/pages
|
||||
* with near-identical chrome. They're merged here as URL-linkable tabs
|
||||
* (`?tab=`) on one page — each tab keeps the permission it was individually
|
||||
* gated on before, and just doesn't render if the user lacks it.
|
||||
* Invoices and USD Payments used to be separate routes/pages with
|
||||
* near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
|
||||
* on one page — each tab keeps the permission it was individually gated on
|
||||
* before, and just doesn't render if the user lacks it.
|
||||
*
|
||||
* The Payments tab was removed; its summary (total collected, ETB/USD) now
|
||||
* lives as a card at the top of the Invoices tab instead.
|
||||
*/
|
||||
const TABS = [
|
||||
{
|
||||
@@ -26,14 +28,6 @@ const TABS = [
|
||||
"Every invoice issued across bookings, warehouse fees and clearance charges.",
|
||||
Panel: InvoicesPanel,
|
||||
},
|
||||
{
|
||||
key: "payments",
|
||||
label: "Payments",
|
||||
icon: Wallet,
|
||||
permission: FREIGHT_PERMS.payments.view,
|
||||
subtitle: "View and reconcile booking payment transactions.",
|
||||
Panel: PaymentsPanel,
|
||||
},
|
||||
{
|
||||
key: "usd-payments",
|
||||
label: "USD Payments",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -79,6 +81,21 @@ export default function InvoicesPanel() {
|
||||
[pendingActions],
|
||||
);
|
||||
|
||||
// Summary card: total collected (paidAmount) across every invoice matching
|
||||
// the current search/status filters, not just the visible page.
|
||||
const { data: summary } = useQuery(
|
||||
api.invoices.collectedSummary.queryOptions({
|
||||
input: {
|
||||
filter: { search: debouncedQuery, status: statusFilter || undefined },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||
const etbCollected = summary?.ETB ?? 0;
|
||||
const usdCollected = summary?.USD ?? 0;
|
||||
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
|
||||
const etbFromUsd = rate ? usdCollected * rate : null;
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -170,7 +187,42 @@ export default function InvoicesPanel() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Total collected
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="edr-text">
|
||||
{etbFromUsd !== null
|
||||
? formatMoney(etbCollected + etbFromUsd, "ETB")
|
||||
: formatMoney(etbCollected, "ETB")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{etbFromUsd !== null
|
||||
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD`
|
||||
: "USD rate unavailable — ETB collected only"}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Collected — ETB only
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="edr-text">
|
||||
{formatMoney(etbCollected, "ETB")}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Collected — USD only
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="edr-text">
|
||||
{formatMoney(usdCollected, "USD")}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
@@ -264,6 +316,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
* the container type or bulk commodity the fee covers.
|
||||
*/
|
||||
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
@@ -304,7 +311,8 @@ const unitsForShape = (
|
||||
// Container-only service — per returned container, per wagon, or flat.
|
||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
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
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
// ── Trade direction — Bulk & Container base freight, plus the directed
|
||||
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
|
||||
// container return, which is import-only for now so export is not
|
||||
// offered) ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"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
|
||||
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
// ── Cargo kind — customs clearance and the cancellation fee are priced
|
||||
// separately for containers (one rate per container type) and bulk (one
|
||||
// rate per commodity) ──────────────────────────────────────────────────
|
||||
{
|
||||
name: "cargoKind",
|
||||
label: "Cargo kind",
|
||||
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: INTERCITY_KINDS,
|
||||
placeholder: "Is this fee for containers or bulk?",
|
||||
description:
|
||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||
"Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.",
|
||||
showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
|
||||
// Not a stored column: a container fee carries its containerTypeId, a
|
||||
// bulk fee carries none.
|
||||
// bulk fee its cargoTypeId.
|
||||
getInitialValue: (record) =>
|
||||
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||
},
|
||||
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which container type this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
||||
// ── Bulk cargo type — the bulk fee names its commodity ────────────────
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which bulk commodity this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "BULK",
|
||||
},
|
||||
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||
|
||||
@@ -362,6 +362,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
@@ -742,7 +743,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
||||
rowStyle={(schedule) =>
|
||||
schedule.shippingLineCompanyId
|
||||
? SHIPPING_LINE_ROW_STYLE
|
||||
: directionRowStyle(schedule.direction)
|
||||
}
|
||||
error={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
@@ -1052,6 +1057,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
* bookings that have not paid yet — that space is claimed, so it is not
|
||||
* bookable.
|
||||
*/
|
||||
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
|
||||
const SHIPPING_LINE_ROW_STYLE = {
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
} as const;
|
||||
|
||||
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
if (!schedule.shippingLineCompanyId) return null;
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{schedule.shippingLineCompanyName ?? "Shipping line"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
// renders rather than reading 0 used on every train.
|
||||
@@ -1133,6 +1152,7 @@ function ScheduleCard({
|
||||
withBorder
|
||||
onClick={onOpen}
|
||||
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
style={schedule.shippingLineCompanyId ? SHIPPING_LINE_ROW_STYLE : undefined}
|
||||
>
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
@@ -1182,6 +1202,7 @@ function ScheduleCard({
|
||||
{schedule.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={schedule} />
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
} from "@/types/fileUploadSettings";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -3137,6 +3138,16 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||
),
|
||||
|
||||
collectedSummary: endpoint<
|
||||
{ filter: Omit<InvoiceListFilter, "page" | "pageSize"> },
|
||||
InvoiceCollectedSummary
|
||||
>(
|
||||
"invoices",
|
||||
"collectedSummary",
|
||||
({ filter }) => invoicesService.collectedSummary(filter),
|
||||
({ filter }) => QUERY_KEYS.INVOICES.summary(filter),
|
||||
),
|
||||
|
||||
listOfflineUsd: endpoint<
|
||||
{ filter: InvoiceListFilter },
|
||||
PaginatedOfflineUsdInvoices
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -23,6 +24,17 @@ export const invoicesService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Total collected (paidAmount) across every filtered invoice, by currency. */
|
||||
collectedSummary(
|
||||
filter: Omit<InvoiceListFilter, "page" | "pageSize">,
|
||||
): Promise<InvoiceCollectedSummary> {
|
||||
return apiClient
|
||||
.get<InvoiceCollectedSummary>(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, {
|
||||
params: cleanParams(filter),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<Invoice> {
|
||||
return apiClient
|
||||
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||
|
||||
@@ -39,3 +39,6 @@ export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
export type InvoiceCollectedSummary = Record<string, number>;
|
||||
|
||||
@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType?: FreightType | null;
|
||||
/** Set when the departure is dedicated to one shipping line (hidden from customers). */
|
||||
shippingLineCompanyId?: string | null;
|
||||
shippingLineCompanyName?: string | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user