mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
@@ -266,6 +267,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usd-payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
|
||||
<UsdPaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={
|
||||
|
||||
@@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
||||
|
||||
const fmt = (n: number) =>
|
||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
{li.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Number(li.amount).toLocaleString()} {li.currency}
|
||||
{Number(li.amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
{li.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
`${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
/**
|
||||
* Cargo costs (booking-level totals) plus the same truck-import block the
|
||||
|
||||
@@ -348,6 +348,9 @@ export default function GlCreateBookingForm() {
|
||||
// Intercity shipments ride a passing import/export train staff pick at
|
||||
// finalize time — no shipment day is chosen and no window gate applies.
|
||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||
// USD billing is offered on import traffic only — export and domestic
|
||||
// shipments are always invoiced in ETB.
|
||||
const isImport = contract?.tradeDirection === "IMPORT";
|
||||
|
||||
// ONE_TIME split-remainder mode: a previous booking on this contract was
|
||||
// split on train capacity, so the capacity endpoint reports the outstanding
|
||||
@@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() {
|
||||
Billing currency
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
Shipments are invoiced in ETB.
|
||||
{isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
value={isIntercity ? "ETB" : paymentCurrency}
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={isIntercity}
|
||||
allowUsd={isImport}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -111,6 +112,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
icon: <Receipt />,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
},
|
||||
{
|
||||
label: "USD Payments",
|
||||
href: "/dashboard/usd-payments",
|
||||
icon: <Landmark />,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
href: "/dashboard/support",
|
||||
|
||||
@@ -561,7 +561,7 @@ const RuleEngineFormDialog = ({
|
||||
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
||||
// so let the field carry decimals and let a 400 catch the rest.
|
||||
step={isNumber ? "any" : undefined}
|
||||
disabled={field.disabled || computed !== undefined}
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
|
||||
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
|
||||
@@ -51,6 +51,8 @@ export const QUERY_KEYS = {
|
||||
list: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "offline-usd", filter ?? {}] as const,
|
||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||
},
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export const URL_CONSTANTS = {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||
},
|
||||
|
||||
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
|
||||
@@ -153,6 +155,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||
EXPORT_HANDOVER_MODE: (id: string) =>
|
||||
`/bookings/${id}/export-handover-mode`,
|
||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const FREIGHT_PERMS = {
|
||||
invoices: {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
@@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{booking.tradeDirection === "EXPORT" && (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
How the cargo reaches the train
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
||||
data={[
|
||||
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
||||
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
||||
]}
|
||||
onChange={async (value) => {
|
||||
try {
|
||||
await bookingsService.setExportHandoverMode(
|
||||
booking.id,
|
||||
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||
);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not change the handover mode",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
||||
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
||||
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
{booking.isGovernment && booking.contractSummary && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
InvoiceStatusBadge,
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* The customer's pay window, counted down live. Finance must confirm the bank
|
||||
* transfer before it closes — past the deadline the booking expires like any
|
||||
* unpaid one and the API refuses the confirmation.
|
||||
*/
|
||||
function formatRemaining(deadlineMs: number, now: number): string | null {
|
||||
const diff = deadlineMs - now;
|
||||
if (diff <= 0) return null;
|
||||
const total = Math.floor(diff / 1000);
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return days > 0
|
||||
? `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!deadline) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadline]);
|
||||
|
||||
if (!deadline) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = formatRemaining(new Date(deadline).getTime(), now);
|
||||
if (!remaining) {
|
||||
return (
|
||||
<Badge color="red" variant="light" radius="sm">
|
||||
Window closed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="sm" fw={600} c="edr-text" ff="monospace">
|
||||
{remaining}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** True once the pay window has closed — the API refuses confirmation then. */
|
||||
function windowClosed(row: OfflineUsdInvoice): boolean {
|
||||
const deadline = row.booking?.paymentDeadline;
|
||||
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
|
||||
}
|
||||
|
||||
export default function UsdPaymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
|
||||
const { user } = useAuth();
|
||||
const canConfirm = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const closeConfirm = () => {
|
||||
setConfirming(null);
|
||||
setSlip(null);
|
||||
setReference("");
|
||||
};
|
||||
|
||||
const submitConfirm = async () => {
|
||||
if (!confirming || !slip) return;
|
||||
try {
|
||||
await confirm.mutateAsync({
|
||||
id: confirming.id,
|
||||
file: slip,
|
||||
reference: reference.trim() || undefined,
|
||||
});
|
||||
toast.success(`${confirming.invoiceNumber} confirmed as paid`);
|
||||
closeConfirm();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Confirmation failed");
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<OfflineUsdInvoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoiceNumber",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.invoiceNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "billedTo",
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
{row.original.company?.name ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original.booking;
|
||||
if (!booking) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(row.original.source)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{booking.reference}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: "Amount",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
header: "Balance",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatMoney(row.original.balanceAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payWindow",
|
||||
header: "Pay window",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
const paid = row.original.status === "PAID";
|
||||
if (paid || !canConfirm) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={windowClosed(row.original)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirming(row.original);
|
||||
}}
|
||||
>
|
||||
Confirm paid
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[canConfirm, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="USD Payments"
|
||||
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Refresh"
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "open"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "open" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "Awaiting payment", value: "open" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No USD invoices match your search."
|
||||
: "No USD invoices awaiting confirmation."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load USD 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>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm bank transfer payment</Text>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
{confirming && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Confirming settles {confirming.invoiceNumber} in full (
|
||||
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
|
||||
marks the booking as paid. Upload the customer's bank slip
|
||||
first — this cannot be undone.
|
||||
</Text>
|
||||
|
||||
<PhasedFileDropzone
|
||||
label="Bank payment slip"
|
||||
description="PDF or image of the customer's transfer slip."
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Bank reference"
|
||||
description="Optional — the transfer reference from the slip."
|
||||
placeholder="e.g. FT24091234567"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeConfirm}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={confirm.isPending}
|
||||
disabled={!slip}
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={() => void submitConfirm()}
|
||||
>
|
||||
Confirm as paid
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export interface FormFieldDef {
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
/** Editable on create, locked when editing an existing record. */
|
||||
disabledOnEdit?: boolean;
|
||||
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||
suffix?: string;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
@@ -389,7 +391,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true, disabledOnEdit: true },
|
||||
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||
{
|
||||
name: "wagonTypeIds",
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import {
|
||||
@@ -2917,6 +2918,29 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||
),
|
||||
|
||||
listOfflineUsd: endpoint<
|
||||
{ filter: InvoiceListFilter },
|
||||
PaginatedOfflineUsdInvoices
|
||||
>(
|
||||
"invoices",
|
||||
"listOfflineUsd",
|
||||
({ filter }) => invoicesService.listOfflineUsd(filter),
|
||||
({ filter }) => QUERY_KEYS.INVOICES.offlineUsd(filter),
|
||||
),
|
||||
|
||||
confirmOffline: endpoint<
|
||||
{ id: string; file: File; reference?: string },
|
||||
Invoice
|
||||
>(
|
||||
"invoices",
|
||||
"confirmOffline",
|
||||
({ id, file, reference }) =>
|
||||
invoicesService.confirmOffline(id, file, reference),
|
||||
undefined,
|
||||
// Settling the invoice also advances the booking, so refresh both trees.
|
||||
() => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT],
|
||||
),
|
||||
|
||||
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
|
||||
"invoices",
|
||||
"eimsStatus",
|
||||
|
||||
@@ -458,6 +458,13 @@ export const bookingsService = {
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
},
|
||||
|
||||
setExportHandoverMode: async (
|
||||
id: string,
|
||||
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||
): Promise<void> => {
|
||||
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
|
||||
},
|
||||
|
||||
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
||||
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||
responseType: "blob",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
@@ -33,4 +34,25 @@ export const invoicesService = {
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||
listOfflineUsd(
|
||||
filter: InvoiceListFilter,
|
||||
): Promise<PaginatedOfflineUsdInvoices> {
|
||||
return apiClient
|
||||
.get<PaginatedOfflineUsdInvoices>(URL_CONSTANTS.BILLING.OFFLINE_USD, {
|
||||
params: cleanParams(filter),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
|
||||
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (reference) body.append("reference", reference);
|
||||
return apiClient
|
||||
.post<Invoice>(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -168,6 +168,11 @@ export interface BookingDetail {
|
||||
contractType: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/**
|
||||
* EXPORT only. DIRECT_TO_TRAIN = customer truck loads straight onto the wagon
|
||||
* (no warehouse, no GRN). null/WAREHOUSE = received and GRN'd first.
|
||||
*/
|
||||
exportHandoverMode?: "DIRECT_TO_TRAIN" | "WAREHOUSE" | null;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
@@ -20,3 +20,22 @@ export interface PaginatedInvoices {
|
||||
items: Invoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
|
||||
* carry the shipment's pay-window deadline so the list can show the same
|
||||
* countdown the customer sees — Finance must confirm before it closes.
|
||||
*/
|
||||
export interface OfflineUsdInvoice extends Invoice {
|
||||
booking: {
|
||||
id: string;
|
||||
reference: string;
|
||||
paymentDeadline: string | null;
|
||||
paymentStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user