mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
feat(billing): filter invoices and manual payments by invoice type
Adds a free-form `types` CSV filter to the invoice list DTO and query (same treatment as `paymentMethods` — each billing source mints its own type string, so an IsIn would drop real values), carries it into the invoices export dataset, and surfaces a Type column plus filter pill on both the Invoices and Manual Payments tables.
This commit is contained in:
@@ -116,6 +116,8 @@ export interface InvoiceListFilters {
|
||||
status?: Freight.InvoiceStatus;
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
sources?: string[];
|
||||
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
|
||||
types?: string[];
|
||||
eimsStatuses?: string[];
|
||||
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
||||
paymentMethods?: string[];
|
||||
@@ -306,6 +308,9 @@ export class BillingService {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.types?.length) {
|
||||
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
eimsStatuses: filter.eimsStatuses,
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
|
||||
search: "INV-2026",
|
||||
statuses: "PENDING,OVERDUE",
|
||||
sources: "booking,warehouse",
|
||||
types: "PREPAID,WAGON_CANCEL_FEE",
|
||||
eimsStatuses: "NOT_SUBMITTED",
|
||||
currency: "etb",
|
||||
issuedFrom: "2026-08-01T00:00:00.000Z",
|
||||
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
|
||||
expect(errors).toEqual([]);
|
||||
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
|
||||
expect(dto.sources).toEqual(["booking", "warehouse"]);
|
||||
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
|
||||
expect(dto.currency).toBe("ETB");
|
||||
expect(dto.minAmount).toBe(100);
|
||||
expect(dto.hasBalance).toBe(true);
|
||||
|
||||
@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
|
||||
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
|
||||
sources?: Freight.InvoiceSource[];
|
||||
|
||||
/**
|
||||
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
|
||||
* like `paymentMethods`: every billing source mints its own `type` string, so
|
||||
* an `IsIn` here would silently drop a real value.
|
||||
*/
|
||||
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
types?: string[];
|
||||
|
||||
/** MoR filing state — Finance's "what still needs registering" cut. */
|
||||
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
|
||||
@IsOptional()
|
||||
|
||||
@@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = {
|
||||
// on-screen filter actually carries into the export.
|
||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
||||
{ key: 'types', label: 'Type', type: 'multiselect' },
|
||||
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
||||
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
@@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = {
|
||||
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||
const sources = params.sources as string[] | null;
|
||||
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
||||
const types = params.types as string[] | null;
|
||||
if (types?.length) qb.andWhere('i.type IN (:...types)', { types });
|
||||
const eimsStatuses = params.eimsStatuses as string[] | null;
|
||||
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
||||
const paymentMethods = params.paymentMethods as string[] | null;
|
||||
|
||||
@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoicePaymentMethod,
|
||||
invoiceTypeLabel,
|
||||
paymentMethodLabel,
|
||||
type Invoice,
|
||||
type InvoiceListFilter,
|
||||
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
|
||||
{ key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Currency",
|
||||
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
|
||||
size: 220,
|
||||
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1200}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -44,7 +44,9 @@ import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings"
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoiceTypeLabel,
|
||||
type InvoiceListFilter,
|
||||
type OfflineUsdInvoice,
|
||||
} from "@/types/invoice";
|
||||
@@ -109,6 +111,13 @@ const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
|
||||
secondary: true,
|
||||
options: SOURCE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
label: "Type",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: INVOICE_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "paymentMethods",
|
||||
label: "Payment method",
|
||||
@@ -424,6 +433,15 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1160}>
|
||||
<Box miw={1320}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface InvoiceListFilter {
|
||||
statuses?: string;
|
||||
/** CSV of `Freight.InvoiceSource` values. */
|
||||
sources?: string;
|
||||
/** CSV of invoice types (see `INVOICE_TYPE_OPTIONS`). */
|
||||
types?: string;
|
||||
/** CSV of EIMS filing states. */
|
||||
eimsStatuses?: string;
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
@@ -171,3 +173,28 @@ export function invoicePaymentMethod(invoice: Invoice): string | null {
|
||||
/** Human label for a method value; unknown values are shown as-is. */
|
||||
export const paymentMethodLabel = (method: string): string =>
|
||||
PAYMENT_METHOD_LABELS.get(method) ?? method;
|
||||
|
||||
/**
|
||||
* What an invoice bills for. Every billing source mints its own `type` string,
|
||||
* so this list is the known vocabulary, not a closed enum — render an unknown
|
||||
* value rather than treating it as invalid.
|
||||
*/
|
||||
export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "PREPAID", label: "Prepaid freight" },
|
||||
{ value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" },
|
||||
{ value: "GL_FINAL", label: "General contract final" },
|
||||
{ value: "ADDITIONAL_CHARGE", label: "Additional charge" },
|
||||
{ value: "PORT_CHARGES", label: "Port charges" },
|
||||
{ value: "MISCELLANEOUS", label: "Miscellaneous" },
|
||||
{ value: "DELIVERY_FEE", label: "Delivery fee" },
|
||||
{ value: "LAST_MILE_ADVANCE", label: "Last-mile advance" },
|
||||
{ value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" },
|
||||
{ value: "STORAGE_FEE", label: "Storage fee" },
|
||||
{ value: "DEMURRAGE", label: "Demurrage" },
|
||||
{ value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" },
|
||||
];
|
||||
|
||||
/** Label for an invoice `type`, falling back to the humanised raw value. */
|
||||
export const invoiceTypeLabel = (type: string): string =>
|
||||
INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ??
|
||||
type.replace(/_/g, " ");
|
||||
|
||||
Reference in New Issue
Block a user