mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
# Copy to .env for local/docker compose (not committed).
|
# Copy to .env for local/docker compose (not committed).
|
||||||
|
|
||||||
|
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
|
||||||
|
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
|
||||||
|
# (canned verified profile, no eSignet call). Leave unset in production.
|
||||||
|
ENV=
|
||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||||
|
|||||||
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Dev/staging bypass gate for OTP, payment and Fayda verification.
|
||||||
|
*
|
||||||
|
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
|
||||||
|
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
|
||||||
|
* production, so this is always false there.
|
||||||
|
*/
|
||||||
|
export function isBypassEnv(): boolean {
|
||||||
|
return ["dev", "staging"].includes(process.env.ENV ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
|
||||||
|
export const DEV_BYPASS_OTP = "000000";
|
||||||
@@ -41,4 +41,16 @@ export class PaginationQueryDto {
|
|||||||
@Transform(({ value }) => String(value).toUpperCase())
|
@Transform(({ value }) => String(value).toUpperCase())
|
||||||
@IsIn(['ASC', 'DESC'])
|
@IsIn(['ASC', 'DESC'])
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column to sort by, as a public field name (not a raw SQL column). The
|
||||||
|
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
|
||||||
|
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
|
||||||
|
* An unrecognized value falls back silently rather than 400ing, so a stale
|
||||||
|
* bookmark or shared link never breaks.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
|
||||||
|
sortBy?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
export interface FacetBucket {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `GROUP BY` query per faceted column, each with every OTHER active
|
||||||
|
* filter applied but its OWN predicate omitted. That omission is the point:
|
||||||
|
* with `status=SUBMITTED` selected, the status facet still reports
|
||||||
|
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
|
||||||
|
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
|
||||||
|
* not a pill, and stays applied in every facet.
|
||||||
|
*
|
||||||
|
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
|
||||||
|
* have real cardinality; beyond 50 the frontend should fall back to a
|
||||||
|
* typeahead instead of a checkbox list. Never facet a column whose popover
|
||||||
|
* would need its own search box (references, plate numbers, free text).
|
||||||
|
*
|
||||||
|
* @param base builds a FRESH query builder (soft-delete guard only,
|
||||||
|
* no filters) — called once per facet column.
|
||||||
|
* @param applyFilters applies every filter to `qb`, using `omit` to skip
|
||||||
|
* one column's own predicate.
|
||||||
|
* @param columns facet key -> "alias.column" SQL reference.
|
||||||
|
*/
|
||||||
|
export async function computeFacets<T extends ObjectLiteral>(
|
||||||
|
base: () => SelectQueryBuilder<T>,
|
||||||
|
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
|
||||||
|
columns: Record<string, string>,
|
||||||
|
): Promise<Record<string, FacetBucket[]>> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
Object.entries(columns).map(async ([key, column]) => {
|
||||||
|
const qb = base();
|
||||||
|
applyFilters(qb, key);
|
||||||
|
const rows = await qb
|
||||||
|
.select(column, 'value')
|
||||||
|
.addSelect('COUNT(*)::int', 'count')
|
||||||
|
.andWhere(`${column} IS NOT NULL`)
|
||||||
|
.groupBy(column)
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(50)
|
||||||
|
.getRawMany<{ value: string; count: number }>();
|
||||||
|
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { SelectQueryBuilder } from 'typeorm';
|
||||||
|
import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util';
|
||||||
|
|
||||||
|
/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */
|
||||||
|
function fakeQb() {
|
||||||
|
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||||
|
const qb = {
|
||||||
|
alias: 'contract',
|
||||||
|
orderBy(...args: unknown[]) {
|
||||||
|
calls.push({ method: 'orderBy', args });
|
||||||
|
return qb;
|
||||||
|
},
|
||||||
|
addOrderBy(...args: unknown[]) {
|
||||||
|
calls.push({ method: 'addOrderBy', args });
|
||||||
|
return qb;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { qb: qb as unknown as SelectQueryBuilder<any>, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTABLE = {
|
||||||
|
createdAt: 'contract.createdAt',
|
||||||
|
contractValidUntil: 'contract.contractValidUntil',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('applySort', () => {
|
||||||
|
it('resolves a whitelisted sortBy to its column', () => {
|
||||||
|
const { qb, calls } = fakeQb();
|
||||||
|
applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt');
|
||||||
|
expect(calls[0]).toEqual({
|
||||||
|
method: 'orderBy',
|
||||||
|
args: ['contract.contractValidUntil', 'ASC'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default column for an unknown sortBy instead of throwing', () => {
|
||||||
|
const { qb, calls } = fakeQb();
|
||||||
|
// A stale bookmark or shared link naming a removed/renamed column must
|
||||||
|
// never 400 — it should silently behave as if sortBy were absent.
|
||||||
|
expect(() =>
|
||||||
|
applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults sortOrder to DESC when absent or not ASC', () => {
|
||||||
|
const { qb, calls } = fakeQb();
|
||||||
|
applySort(qb, {}, SORTABLE, 'createdAt');
|
||||||
|
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always appends an id ASC tiebreaker', () => {
|
||||||
|
const { qb, calls } = fakeQb();
|
||||||
|
applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt');
|
||||||
|
expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizePagination / buildPaginationMeta', () => {
|
||||||
|
it('clamps page to >= 1 and pageSize to the configured max', () => {
|
||||||
|
const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 });
|
||||||
|
expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes hasNextPage/hasPreviousPage from total', () => {
|
||||||
|
const meta = buildPaginationMeta(45, 2, 20);
|
||||||
|
expect(meta).toEqual({
|
||||||
|
page: 2,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 45,
|
||||||
|
totalPages: 3,
|
||||||
|
hasNextPage: true,
|
||||||
|
hasPreviousPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,3 +83,32 @@ export function paginateArray<T>(
|
|||||||
meta: buildPaginationMeta(rows.length, page, pageSize),
|
meta: buildPaginationMeta(rows.length, page, pageSize),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a
|
||||||
|
* whitelist — never interpolate `sortBy` into a query builder directly, it is
|
||||||
|
* unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)`
|
||||||
|
* is a SQL-injection primitive (see the deleted `findAllWithFilters` methods
|
||||||
|
* on drivers/vehicles repositories, which had exactly that bug).
|
||||||
|
*
|
||||||
|
* An unknown `sortBy` falls back to `fallback` instead of throwing — a stale
|
||||||
|
* bookmark or shared link should never 400.
|
||||||
|
*
|
||||||
|
* Always appends `id ASC` as a tiebreaker: sorting by a non-unique column
|
||||||
|
* (status, createdAt on bulk-imported rows) without one can drop or
|
||||||
|
* duplicate rows across pages once LIMIT/OFFSET is involved.
|
||||||
|
*
|
||||||
|
* @param sortable public sort key -> "alias.column" SQL reference. Also
|
||||||
|
* doubles as the Swagger enum / frontend's sortable-column list.
|
||||||
|
* @param fallback a key that must exist in `sortable`.
|
||||||
|
*/
|
||||||
|
export function applySort<T extends ObjectLiteral>(
|
||||||
|
qb: SelectQueryBuilder<T>,
|
||||||
|
query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' },
|
||||||
|
sortable: Record<string, string>,
|
||||||
|
fallback: string,
|
||||||
|
): SelectQueryBuilder<T> {
|
||||||
|
const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback];
|
||||||
|
qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC');
|
||||||
|
return qb.addOrderBy(`${qb.alias}.id`, 'ASC');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supports the Stripe-style pill filter bar: every list it lands on filters
|
||||||
|
* and sorts server-side now. `@Index` decorators alone do nothing —
|
||||||
|
* `synchronize: false` means an index exists only if a migration created it
|
||||||
|
* (see the `RepairSynchronizeDrift`-style gaps this closes).
|
||||||
|
*
|
||||||
|
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
|
||||||
|
* and wagons have no plain `status` index — `idx_bookings_route_day` and
|
||||||
|
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
|
||||||
|
* not a standalone `WHERE status = $1`, and `status` is the single
|
||||||
|
* most-filtered column on both lists (bookings: 37 values).
|
||||||
|
*
|
||||||
|
* `(created_at DESC, id ASC)` partials match the default sort + id
|
||||||
|
* tiebreaker `applySort` now appends everywhere, and none of these tables
|
||||||
|
* had a created_at index at all.
|
||||||
|
*/
|
||||||
|
export class FilterableListIndexes3540000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_status
|
||||||
|
ON freight.bookings USING btree (status)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagons_status
|
||||||
|
ON freight.wagons USING btree (status)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
|
||||||
|
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
|
||||||
|
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
|
||||||
|
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
|
||||||
|
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint,
|
||||||
|
* distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original
|
||||||
|
* invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`.
|
||||||
|
*/
|
||||||
|
export class EimsDebitCreditNotes3550000000000 implements MigrationInterface {
|
||||||
|
name = "EimsDebitCreditNotes3550000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV',
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_document_type,
|
||||||
|
DROP COLUMN IF EXISTS eims_reason,
|
||||||
|
DROP COLUMN IF EXISTS related_invoice_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
|
|||||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
|
import { IssueMemoDto } from "./dto/issue-memo.dto";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
|||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
FREIGHT_PERMS.invoices.confirmOffline,
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
|
FREIGHT_PERMS.invoices.memoIssue,
|
||||||
])
|
])
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
@@ -116,11 +119,31 @@ export class BillingController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("invoices/:id/memo")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
|
||||||
|
})
|
||||||
|
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
|
||||||
|
return this.billingService.issueMemo(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("invoices/:id/document")
|
@Get("invoices/:id/document")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
@ApiOperation({
|
||||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
summary:
|
||||||
const { filename, buffer } = await this.billingService.document(id);
|
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
|
||||||
|
})
|
||||||
|
async document(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query("format") format: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (format !== undefined && format !== "a4" && format !== "thermal") {
|
||||||
|
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
|
||||||
|
}
|
||||||
|
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
|
||||||
sendPdf(res, filename, buffer);
|
sendPdf(res, filename, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BillingService.issueMemo", () => {
|
||||||
|
const ORIGINAL_ID = "original-invoice-1";
|
||||||
|
|
||||||
|
function originalInvoice(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: ORIGINAL_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
eimsIrn: "irn-value",
|
||||||
|
eimsDocumentType: "INV",
|
||||||
|
eimsStatus: "REGISTERED",
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: "booking-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
companyProfileId: "profile-1",
|
||||||
|
shippingLineCompanyId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
totalAmount: 1500,
|
||||||
|
lines: [
|
||||||
|
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
|
||||||
|
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(original: ReturnType<typeof originalInvoice>) {
|
||||||
|
const savedLines: unknown[] = [];
|
||||||
|
const manager = makeManager(savedLines);
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
const invoices = { findById: jest.fn().mockResolvedValue(original) };
|
||||||
|
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
|
||||||
|
const service = new BillingService(
|
||||||
|
dataSource as never,
|
||||||
|
invoices as never,
|
||||||
|
invoiceLines as never,
|
||||||
|
makeEvents() as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ get: () => undefined } as never,
|
||||||
|
);
|
||||||
|
return { service, manager, savedLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
|
||||||
|
const { service, savedLines } = build(originalInvoice());
|
||||||
|
|
||||||
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
|
||||||
|
|
||||||
|
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
|
||||||
|
expect(memo.totalAmount).toBe(1500);
|
||||||
|
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
|
||||||
|
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
|
||||||
|
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
|
||||||
|
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
|
||||||
|
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
|
||||||
|
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
|
||||||
|
const { service } = build(originalInvoice());
|
||||||
|
|
||||||
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
|
||||||
|
|
||||||
|
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
|
||||||
|
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
expect(memo.balanceAmount).toBe(1500);
|
||||||
|
expect(memo.paidAmount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
|
||||||
|
const { service } = build(originalInvoice());
|
||||||
|
|
||||||
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
|
||||||
|
|
||||||
|
expect(memo.sourceId).toBe(ORIGINAL_ID);
|
||||||
|
expect(memo.sourceId).not.toBe("booking-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a partial memo with explicit lines instead of copying the original", async () => {
|
||||||
|
const { service } = build(originalInvoice());
|
||||||
|
|
||||||
|
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||||
|
type: "CRE",
|
||||||
|
reason: "Partial credit",
|
||||||
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(memo.totalAmount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a memo against an invoice never registered with EIMS", async () => {
|
||||||
|
const { service } = build(originalInvoice({ eimsIrn: null }));
|
||||||
|
|
||||||
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
|
||||||
|
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a memo against a memo", async () => {
|
||||||
|
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
|
||||||
|
|
||||||
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
|
||||||
|
"cannot issue a memo against a memo",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a memo against an EIMS-cancelled invoice", async () => {
|
||||||
|
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
|
||||||
|
|
||||||
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
|
||||||
|
"cancelled with EIMS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credit memo whose total exceeds the original", async () => {
|
||||||
|
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.issueMemo(ORIGINAL_ID, {
|
||||||
|
type: "CRE",
|
||||||
|
reason: "too much",
|
||||||
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/exceeds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
|
||||||
|
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||||
|
|
||||||
|
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||||
|
type: "DEB",
|
||||||
|
reason: "additional charge",
|
||||||
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(memo.totalAmount).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a blank reason", async () => {
|
||||||
|
const { service } = build(originalInvoice());
|
||||||
|
|
||||||
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
|
||||||
|
"requires a reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("BillingService.markInvoiceAsPaid", () => {
|
describe("BillingService.markInvoiceAsPaid", () => {
|
||||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||||
const open = {
|
const open = {
|
||||||
@@ -774,6 +927,7 @@ describe("BillingService.document", () => {
|
|||||||
|
|
||||||
const build = (invoice: Record<string, unknown>) => {
|
const build = (invoice: Record<string, unknown>) => {
|
||||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||||
|
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||||
const service = new BillingService(
|
const service = new BillingService(
|
||||||
{} as never,
|
{} as never,
|
||||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||||
@@ -781,7 +935,7 @@ describe("BillingService.document", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ render } as never,
|
{ render, renderThermal } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{
|
{
|
||||||
get: (key: string) =>
|
get: (key: string) =>
|
||||||
@@ -790,7 +944,7 @@ describe("BillingService.document", () => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
} as never, // config
|
} as never, // config
|
||||||
);
|
);
|
||||||
return { service, render };
|
return { service, render, renderThermal };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||||
@@ -847,4 +1001,24 @@ describe("BillingService.document", () => {
|
|||||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls render (not renderThermal) for the default format", async () => {
|
||||||
|
const { service, render, renderThermal } = build(invoiceRow());
|
||||||
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
expect(render).toHaveBeenCalledTimes(1);
|
||||||
|
expect(renderThermal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
||||||
|
const { service, render, renderThermal } = build(invoiceRow());
|
||||||
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await service.document("inv-1", "thermal");
|
||||||
|
|
||||||
|
expect(renderThermal).toHaveBeenCalledTimes(1);
|
||||||
|
expect(render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
|||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
|
pngDataUrl,
|
||||||
} from "./documents/invoice-document.service";
|
} from "./documents/invoice-document.service";
|
||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||||
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
|
|||||||
status?: Freight.InvoiceStatus;
|
status?: Freight.InvoiceStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
||||||
|
export type MemoType = "CRE" | "DEB";
|
||||||
|
|
||||||
|
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
||||||
|
export interface IssueMemoInput {
|
||||||
|
type: MemoType;
|
||||||
|
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
||||||
|
reason: string;
|
||||||
|
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
||||||
|
lines?: InvoiceLineInput[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||||
export interface InvoiceEventPayload {
|
export interface InvoiceEventPayload {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
@@ -481,12 +495,20 @@ export class BillingService {
|
|||||||
|
|
||||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
/**
|
||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||||
|
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||||
|
* boundary check, not a business rule.
|
||||||
|
*/
|
||||||
|
async document(
|
||||||
|
id: string,
|
||||||
|
format: "a4" | "thermal" = "a4",
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
return this.invoiceDocuments.render(
|
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||||
await this.toDocumentModel(invoice, "INVOICE"),
|
return format === "thermal"
|
||||||
);
|
? this.invoiceDocuments.renderThermal(model)
|
||||||
|
: this.invoiceDocuments.render(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||||
@@ -502,15 +524,6 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
|
|
||||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
|
|
||||||
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
|
|
||||||
*/
|
|
||||||
private renderEimsQr(signedQr: string): string {
|
|
||||||
return `data:image/png;base64,${signedQr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||||
private async bookingSummaryRows(
|
private async bookingSummaryRows(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
@@ -626,7 +639,7 @@ export class BillingService {
|
|||||||
currency: l.currency,
|
currency: l.currency,
|
||||||
})),
|
})),
|
||||||
totals,
|
totals,
|
||||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,11 +806,16 @@ export class BillingService {
|
|||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
/**
|
||||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
||||||
|
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
||||||
|
* daily sequence (different prefix hashes to a different advisory lock, see
|
||||||
|
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
||||||
|
*/
|
||||||
|
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
||||||
return nextDailyInvoiceNumber(mg, {
|
return nextDailyInvoiceNumber(mg, {
|
||||||
table: "freight.invoices",
|
table: "freight.invoices",
|
||||||
code: "INV",
|
code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,9 +838,123 @@ export class BillingService {
|
|||||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
||||||
|
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
||||||
|
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
||||||
|
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
||||||
|
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
||||||
|
* is just an ordinary invoice with three extra columns set.
|
||||||
|
*
|
||||||
|
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
||||||
|
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
||||||
|
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
||||||
|
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
||||||
|
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
||||||
|
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
||||||
|
*
|
||||||
|
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
||||||
|
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
||||||
|
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
||||||
|
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
||||||
|
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
||||||
|
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
||||||
|
*/
|
||||||
|
async issueMemo(
|
||||||
|
originalId: string,
|
||||||
|
input: IssueMemoInput,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const reason = input.reason?.trim();
|
||||||
|
if (!reason) {
|
||||||
|
throw new BadRequestException("A memo requires a reason.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = await this.findById(originalId);
|
||||||
|
if (!original.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
||||||
|
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
||||||
|
chargeType: l.chargeType,
|
||||||
|
description: l.description,
|
||||||
|
quantity: Number(l.quantity),
|
||||||
|
unitRate: Number(l.unitRate),
|
||||||
|
amount: Number(l.amount),
|
||||||
|
currency: l.currency,
|
||||||
|
metadata: l.metadata ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||||
|
if (!(total > 0)) {
|
||||||
|
throw new BadRequestException("A memo must have a positive total.");
|
||||||
|
}
|
||||||
|
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
||||||
|
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
||||||
|
// assume the credit-note ceiling is correct for DEB).
|
||||||
|
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = input.type === "CRE" ? "CRE" : "DEB";
|
||||||
|
const settled = input.type === "CRE";
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (mg) => {
|
||||||
|
const memo = await this.createInvoice(
|
||||||
|
{
|
||||||
|
source: original.source as Freight.InvoiceSource,
|
||||||
|
sourceId: original.id,
|
||||||
|
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
||||||
|
companyId: original.companyId,
|
||||||
|
companyProfileId: original.companyProfileId,
|
||||||
|
shippingLineCompanyId: original.shippingLineCompanyId,
|
||||||
|
lines,
|
||||||
|
currency: original.currency,
|
||||||
|
subtotalAmount: total,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: total,
|
||||||
|
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||||
|
},
|
||||||
|
mg,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
eimsDocumentType: input.type,
|
||||||
|
eimsReason: reason,
|
||||||
|
relatedInvoiceId: original.id,
|
||||||
|
...(settled
|
||||||
|
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
await mg.update(Invoice, memo.id, patch);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
||||||
|
);
|
||||||
|
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async createInvoice(
|
private async createInvoice(
|
||||||
input: GenerateInvoiceInput,
|
input: GenerateInvoiceInput,
|
||||||
mg: EntityManager,
|
mg: EntityManager,
|
||||||
|
code = "INV",
|
||||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const currency = input.currency ?? "ETB";
|
const currency = input.currency ?? "ETB";
|
||||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||||
@@ -870,7 +1002,7 @@ export class BillingService {
|
|||||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||||
|
|
||||||
const invoice = await mg.save(
|
const invoice = await mg.save(
|
||||||
mg.create(Invoice, {
|
mg.create(Invoice, {
|
||||||
|
|||||||
@@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||||
|
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||||
|
|
||||||
|
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
|
||||||
|
const html = service.buildThermalHtml(model());
|
||||||
|
expect(html).not.toContain('class="seal"');
|
||||||
|
expect(html).not.toContain("seal-image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
|
||||||
|
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||||
|
expect(html).toContain('class="qr"');
|
||||||
|
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||||
|
expect(html).not.toContain("position: absolute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a long IRN summary value rather than truncating it", () => {
|
||||||
|
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
|
||||||
|
expect(html).toContain(irn);
|
||||||
|
expect(html).toContain("overflow-wrap: anywhere");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
|
||||||
|
const html = service.buildThermalHtml(
|
||||||
|
model({
|
||||||
|
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("<table");
|
||||||
|
expect(html).not.toContain("<td");
|
||||||
|
expect(html).toContain("40ft container rail freight");
|
||||||
|
expect(html).toContain("12 x");
|
||||||
|
expect(html).toContain("2,948,207.4 Birr (ETB)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
|
||||||
|
const html = service.buildThermalHtml(model());
|
||||||
|
expect(html).not.toContain("width: 330px");
|
||||||
|
expect(html).not.toContain("right: 160px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,36 @@ import {
|
|||||||
|
|
||||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
|
||||||
|
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
|
||||||
|
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
|
||||||
|
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
|
||||||
|
* same gateway.
|
||||||
|
*/
|
||||||
|
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||||
|
|
||||||
|
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
|
||||||
|
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
|
||||||
|
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
|
||||||
|
|
||||||
|
function esc(value: unknown): string {
|
||||||
|
return String(value ?? "-")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(amount: unknown, currency: string): string {
|
||||||
|
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: unknown): string {
|
||||||
|
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||||
|
}
|
||||||
|
|
||||||
/** One billed line on the document (charge type / fee type agnostic). */
|
/** One billed line on the document (charge type / fee type agnostic). */
|
||||||
export interface InvoiceDocumentLine {
|
export interface InvoiceDocumentLine {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -120,6 +150,117 @@ export class InvoiceDocumentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
|
||||||
|
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
|
||||||
|
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
|
||||||
|
*
|
||||||
|
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
|
||||||
|
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
|
||||||
|
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
|
||||||
|
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
|
||||||
|
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
|
||||||
|
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
|
||||||
|
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
|
||||||
|
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
|
||||||
|
*/
|
||||||
|
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const logoImageUrl =
|
||||||
|
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
|
||||||
|
// Seal deliberately dropped — never fetched, so no stampSettings call either.
|
||||||
|
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
|
||||||
|
|
||||||
|
const html = this.buildThermalHtml(resolvedModel);
|
||||||
|
return {
|
||||||
|
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
|
||||||
|
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||||
|
label: `${model.title} thermal invoice`,
|
||||||
|
thermal: true,
|
||||||
|
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||||
|
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
|
||||||
|
noFallback: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildThermalHtml(model: InvoiceDocumentModel): string {
|
||||||
|
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||||
|
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
|
||||||
|
|
||||||
|
const summaryRows = model.summary
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const itemBlocks = model.lines
|
||||||
|
.map((item) => {
|
||||||
|
const currency = item.currency ?? model.currency;
|
||||||
|
return `<div class="item">
|
||||||
|
<div class="item-desc">${esc(item.description)}</div>
|
||||||
|
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const totalRows = model.totals
|
||||||
|
.map(
|
||||||
|
(total) =>
|
||||||
|
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const qrMarkup = model.qrImageUrl
|
||||||
|
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${esc(heading)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
|
||||||
|
.doc { width: 100%; box-sizing: border-box; }
|
||||||
|
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
|
||||||
|
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
|
||||||
|
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
|
||||||
|
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
|
||||||
|
.row .label { color: #64748b; white-space: nowrap; }
|
||||||
|
.row .value { text-align: right; overflow-wrap: anywhere; }
|
||||||
|
.item { margin: 4px 0; }
|
||||||
|
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
|
||||||
|
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||||
|
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||||
|
.qr { text-align: center; margin: 8px 0; }
|
||||||
|
.qr img { width: 150px; height: 150px; }
|
||||||
|
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||||
|
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
${logoInner}
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<div class="title">${esc(heading)}</div>
|
||||||
|
<div class="meta">${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}</div>
|
||||||
|
<div class="rule"></div>
|
||||||
|
${summaryRows}
|
||||||
|
<div class="rule"></div>
|
||||||
|
${itemBlocks}
|
||||||
|
<div class="rule"></div>
|
||||||
|
${totalRows}
|
||||||
|
${qrMarkup}
|
||||||
|
<div class="footer">Thank you</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||||
@@ -241,18 +382,7 @@ export class InvoiceDocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildHtml(model: InvoiceDocumentModel): string {
|
buildHtml(model: InvoiceDocumentModel): string {
|
||||||
const esc = (value: unknown) =>
|
const date = formatDate;
|
||||||
String(value ?? "-")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
const money = (amount: unknown, currency = model.currency) =>
|
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
|
||||||
const date = (value: unknown) =>
|
|
||||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
|
||||||
|
|
||||||
const showCategory = Boolean(model.categoryHeader);
|
const showCategory = Boolean(model.categoryHeader);
|
||||||
const sealText =
|
const sealText =
|
||||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||||
@@ -283,7 +413,7 @@ export class InvoiceDocumentService {
|
|||||||
const totalRows = model.totals
|
const totalRows = model.totals
|
||||||
.map(
|
.map(
|
||||||
(total) =>
|
(total) =>
|
||||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
|
|||||||
}
|
}
|
||||||
</style>`;
|
</style>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
||||||
|
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
||||||
|
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
||||||
|
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
||||||
|
*/
|
||||||
|
const THERMAL_PAGE_WIDTH_MM = 80;
|
||||||
|
const THERMAL_MARGIN_MM = 4;
|
||||||
|
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
||||||
|
const THERMAL_FEED_MM = 6;
|
||||||
|
/** Guard against a runaway line-item list producing an absurd page. */
|
||||||
|
const THERMAL_MAX_HEIGHT_MM = 1500;
|
||||||
|
|
||||||
export interface PdfRenderOptions {
|
export interface PdfRenderOptions {
|
||||||
/** Label used in logs to identify the document kind. */
|
/** Label used in logs to identify the document kind. */
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||||
landscape?: boolean;
|
landscape?: boolean;
|
||||||
|
/**
|
||||||
|
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
||||||
|
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
||||||
|
* `format: "A4"`.
|
||||||
|
*/
|
||||||
|
thermal?: boolean;
|
||||||
|
/**
|
||||||
|
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
||||||
|
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
||||||
|
* output" (it silently hands back a different document shape than what was asked for); the
|
||||||
|
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
||||||
|
* also supplied — an explicit fallback always wins.
|
||||||
|
*/
|
||||||
|
noFallback?: boolean;
|
||||||
/**
|
/**
|
||||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||||
* header). When omitted, a generic single-page fallback is produced.
|
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
||||||
|
* produced.
|
||||||
*/
|
*/
|
||||||
fallback?: (preparedHtml: string) => Buffer;
|
fallback?: (preparedHtml: string) => Buffer;
|
||||||
}
|
}
|
||||||
@@ -54,17 +82,31 @@ export class PdfRenderService {
|
|||||||
const browser = await puppeteer.default.launch(launchOptions);
|
const browser = await puppeteer.default.launch(launchOptions);
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
const thermal = opts.thermal ?? false;
|
||||||
|
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||||
|
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
||||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||||
await page.emulateMediaType("print");
|
await page.emulateMediaType("print");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = thermal
|
||||||
format: "A4",
|
? await page.pdf({
|
||||||
landscape: opts.landscape ?? false,
|
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||||
printBackground: true,
|
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
printBackground: true,
|
||||||
});
|
margin: {
|
||||||
|
top: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
||||||
|
left: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
right: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
landscape: opts.landscape ?? false,
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||||
|
});
|
||||||
|
|
||||||
const buffer = Buffer.from(pdf);
|
const buffer = Buffer.from(pdf);
|
||||||
if (!this.isValidPdf(buffer)) {
|
if (!this.isValidPdf(buffer)) {
|
||||||
@@ -79,6 +121,15 @@ export class PdfRenderService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||||
|
if (!opts.fallback && opts.noFallback) {
|
||||||
|
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||||
|
// printer output" — it silently hands back a different document than what was asked for.
|
||||||
|
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
||||||
|
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||||
if (this.isValidPdf(fallback)) {
|
if (this.isValidPdf(fallback)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -92,6 +143,20 @@ export class PdfRenderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
||||||
|
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
||||||
|
* receipt, not a fixed A4-length page with blank space at the bottom.
|
||||||
|
*/
|
||||||
|
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
||||||
|
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
||||||
|
// isn't a known global to type-check against — the string is evaluated in the page's own
|
||||||
|
// browser context regardless, same as the closure form would be.
|
||||||
|
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||||
|
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||||
|
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
||||||
|
}
|
||||||
|
|
||||||
private injectPdfPrintStyles(html: string): string {
|
private injectPdfPrintStyles(html: string): string {
|
||||||
if (html.includes("edr-pdf-print-fix")) return html;
|
if (html.includes("edr-pdf-print-fix")) return html;
|
||||||
if (html.includes("</head>")) {
|
if (html.includes("</head>")) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */
|
||||||
|
export class MemoLineDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
chargeType!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
quantity?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
unitRate?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */
|
||||||
|
export class IssueMemoDto {
|
||||||
|
@ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." })
|
||||||
|
@IsIn(["CRE", "DEB"])
|
||||||
|
type!: "CRE" | "DEB";
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 500)
|
||||||
|
reason!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [MemoLineDto],
|
||||||
|
description: "Omit to copy every line of the original invoice verbatim.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => MemoLineDto)
|
||||||
|
lines?: MemoLineDto[];
|
||||||
|
}
|
||||||
@@ -212,6 +212,59 @@ describe("toEimsInvoice", () => {
|
|||||||
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
|
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => {
|
||||||
|
it("defaults DocumentDetails.Type to INV with no Reason field", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
expect(doc.DocumentDetails.Type).toBe("INV");
|
||||||
|
expect(doc.DocumentDetails).not.toHaveProperty("Reason");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files a credit note with Type, Reason and RelatedDocument", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({
|
||||||
|
documentType: "CRE",
|
||||||
|
reason: "Overbilled freight charge",
|
||||||
|
relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
|
||||||
|
expect(doc.ReferenceDetails.RelatedDocument).toBe(
|
||||||
|
"9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files a debit note the same way", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }),
|
||||||
|
);
|
||||||
|
expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a credit/debit note has no reason", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }),
|
||||||
|
),
|
||||||
|
).toThrow(/needs a reason/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a credit/debit note has no relatedDocument", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }),
|
||||||
|
),
|
||||||
|
).toThrow(/needs.*relatedDocument/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when the lines do not sum to the invoice total", () => {
|
it("throws when the lines do not sum to the invoice total", () => {
|
||||||
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
|
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
|
||||||
/lines sum to 11000 but the invoice total is 9000/,
|
/lines sum to 11000 but the invoice total is 9000/,
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util";
|
|||||||
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
||||||
const EIMS_VERSION = "1";
|
const EIMS_VERSION = "1";
|
||||||
|
|
||||||
/** The only `DocumentDetails.Type` observed in the supplied material. */
|
/**
|
||||||
const EIMS_DOCUMENT_TYPE = "INV";
|
* `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"`
|
||||||
|
* (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no
|
||||||
|
* separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used
|
||||||
|
* ... within the Document Detail object, you should specify DEB for a debit note, CRE for a
|
||||||
|
* credit note... add a Reason attribute under document detail object".
|
||||||
|
*/
|
||||||
|
export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const;
|
||||||
|
export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number];
|
||||||
|
|
||||||
export interface EimsBuyerDetails {
|
export interface EimsBuyerDetails {
|
||||||
City: string | null;
|
City: string | null;
|
||||||
@@ -60,7 +67,9 @@ export interface EimsDocumentDetails {
|
|||||||
DocumentNumber: string;
|
DocumentNumber: string;
|
||||||
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
|
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
|
||||||
Date: string;
|
Date: string;
|
||||||
Type: string;
|
Type: EimsDocumentType;
|
||||||
|
/** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */
|
||||||
|
Reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EimsInvoiceItem {
|
export interface EimsInvoiceItem {
|
||||||
@@ -212,7 +221,18 @@ export interface EimsMapperContext {
|
|||||||
unitDefault: string;
|
unitDefault: string;
|
||||||
incomeWithholdValue: number;
|
incomeWithholdValue: number;
|
||||||
transactionWithholdValue: number;
|
transactionWithholdValue: number;
|
||||||
/** Null for an ordinary invoice; set only for a real related-document case. */
|
/**
|
||||||
|
* `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and
|
||||||
|
* `relatedDocument` become required — confirmed directly by MoR support, not the collection.
|
||||||
|
*/
|
||||||
|
documentType?: EimsDocumentType;
|
||||||
|
/** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */
|
||||||
|
reason?: string | null;
|
||||||
|
/**
|
||||||
|
* `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE —
|
||||||
|
* the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo
|
||||||
|
* from a registered invoice").
|
||||||
|
*/
|
||||||
relatedDocument?: string | null;
|
relatedDocument?: string | null;
|
||||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||||
buyerCountryCode?: string | null;
|
buyerCountryCode?: string | null;
|
||||||
@@ -326,6 +346,26 @@ export function toEimsInvoice(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentType = context.documentType ?? "INV";
|
||||||
|
if (!EIMS_DOCUMENT_TYPES.includes(documentType)) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (documentType !== "INV") {
|
||||||
|
if (!context.reason?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!context.relatedDocument?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` +
|
||||||
|
"relatedDocument — the original registered invoice's IRN",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
|
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
|
||||||
if (Number.isNaN(issuedAt.getTime())) {
|
if (Number.isNaN(issuedAt.getTime())) {
|
||||||
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
||||||
@@ -430,7 +470,8 @@ export function toEimsInvoice(
|
|||||||
DocumentDetails: {
|
DocumentDetails: {
|
||||||
DocumentNumber: context.documentNumber,
|
DocumentNumber: context.documentNumber,
|
||||||
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
||||||
Type: EIMS_DOCUMENT_TYPE,
|
Type: documentType,
|
||||||
|
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
|
||||||
},
|
},
|
||||||
ItemList,
|
ItemList,
|
||||||
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
||||||
|
|||||||
@@ -180,4 +180,27 @@ export class Invoice extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||||
eimsCancellationRemark?: string | null;
|
eimsCancellationRemark?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed
|
||||||
|
* by MoR support directly (not the collection): debit/credit notes go through this same
|
||||||
|
* `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via
|
||||||
|
* `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create
|
||||||
|
* debit/credit note invoices — that is a freight-workflow decision — it only files one
|
||||||
|
* correctly once these columns are set on an existing row.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" })
|
||||||
|
eimsDocumentType!: string;
|
||||||
|
|
||||||
|
/** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */
|
||||||
|
@Column({ name: "eims_reason", type: "text", nullable: true })
|
||||||
|
eimsReason?: string | null;
|
||||||
|
|
||||||
|
/** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */
|
||||||
|
@Column({ name: "related_invoice_id", type: "uuid", nullable: true })
|
||||||
|
relatedInvoiceId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice)
|
||||||
|
@JoinColumn({ name: "related_invoice_id" })
|
||||||
|
relatedInvoice?: Invoice | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectQueryBuilder,
|
SelectQueryBuilder,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
@@ -857,6 +858,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
||||||
|
* column, each with every OTHER active filter applied but its own
|
||||||
|
* predicate omitted (see `applyListFilters`'s `omit` param). `bookingType`
|
||||||
|
* is derived from `contract_kind` (see the comment in `applyListFilters`),
|
||||||
|
* not a plain column, so it facets on the same CASE expression the filter
|
||||||
|
* itself applies.
|
||||||
|
*/
|
||||||
|
async getFacets(options: BookingListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
||||||
|
return computeFacets(
|
||||||
|
() => this.repository.createQueryBuilder('booking').where('booking.deleted_at IS NULL'),
|
||||||
|
(qb, omit) => this.applyListFilters(qb, options, omit as keyof BookingListFilterOptions),
|
||||||
|
{
|
||||||
|
status: 'booking.status',
|
||||||
|
freightType: 'booking.freight_type',
|
||||||
|
tradeDirection: 'booking.trade_direction',
|
||||||
|
paymentStatus: 'booking.payment_status',
|
||||||
|
bookingType:
|
||||||
|
"CASE WHEN booking.contract_kind = 'GENERAL' THEN 'GENERAL_CONTRACT' ELSE 'ONE_TIME' END",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
@@ -915,16 +939,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return { inQueue, onThisPage, needsAction, urgent };
|
return { inQueue, onThisPage, needsAction, urgent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
||||||
|
* own filter doesn't hide its own sibling values. Every other caller
|
||||||
|
* (list, summary metrics) passes nothing.
|
||||||
|
*/
|
||||||
private applyListFilters(
|
private applyListFilters(
|
||||||
qb: SelectQueryBuilder<Booking>,
|
qb: SelectQueryBuilder<Booking>,
|
||||||
options: BookingListFilterOptions,
|
options: BookingListFilterOptions,
|
||||||
|
omit?: keyof BookingListFilterOptions | 'status',
|
||||||
): void {
|
): void {
|
||||||
if (options.statuses?.length) {
|
if (omit !== 'status') {
|
||||||
qb.andWhere('booking.status IN (:...statuses)', {
|
if (options.statuses?.length) {
|
||||||
statuses: options.statuses,
|
qb.andWhere('booking.status IN (:...statuses)', {
|
||||||
});
|
statuses: options.statuses,
|
||||||
} else if (options.status) {
|
});
|
||||||
qb.andWhere('booking.status = :status', { status: options.status });
|
} else if (options.status) {
|
||||||
|
qb.andWhere('booking.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.companyId) {
|
if (options.companyId) {
|
||||||
@@ -957,12 +989,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
cargoTypeId: options.cargoTypeId,
|
cargoTypeId: options.cargoTypeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.freightType) {
|
if (omit !== 'freightType' && options.freightType) {
|
||||||
qb.andWhere('booking.freight_type = :freightType', {
|
qb.andWhere('booking.freight_type = :freightType', {
|
||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.bookingType) {
|
if (omit !== 'bookingType' && options.bookingType) {
|
||||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||||
// drawdowns included — see contract-booking.service create), so the
|
// drawdowns included — see contract-booking.service create), so the
|
||||||
// one-time vs general split keys on the denormalized contract_kind:
|
// one-time vs general split keys on the denormalized contract_kind:
|
||||||
@@ -1011,12 +1043,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
} else if (options.isGovernment === 'false') {
|
} else if (options.isGovernment === 'false') {
|
||||||
qb.andWhere('booking.is_government = FALSE');
|
qb.andWhere('booking.is_government = FALSE');
|
||||||
}
|
}
|
||||||
if (options.tradeDirection) {
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirections) {
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||||
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
||||||
}
|
}
|
||||||
if (options.paymentCurrency) {
|
if (options.paymentCurrency) {
|
||||||
@@ -1024,7 +1056,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
paymentCurrency: options.paymentCurrency,
|
paymentCurrency: options.paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.paymentStatus) {
|
if (omit !== 'paymentStatus' && options.paymentStatus) {
|
||||||
qb.andWhere('booking.payment_status = :paymentStatus', {
|
qb.andWhere('booking.payment_status = :paymentStatus', {
|
||||||
paymentStatus: options.paymentStatus,
|
paymentStatus: options.paymentStatus,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2051,8 +2051,9 @@ export class BookingsService {
|
|||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [statusCounts, metrics] = await Promise.all([
|
const [statusCounts, facets, metrics] = await Promise.all([
|
||||||
this.bookingsRepository.getStatusCounts(),
|
this.bookingsRepository.getStatusCounts(),
|
||||||
|
this.bookingsRepository.getFacets(listFilter),
|
||||||
this.bookingsRepository.getListSummaryMetrics({
|
this.bookingsRepository.getListSummaryMetrics({
|
||||||
...listFilter,
|
...listFilter,
|
||||||
page,
|
page,
|
||||||
@@ -2064,7 +2065,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
metrics,
|
metrics,
|
||||||
|
// Tabs stay unfiltered (whole-set) on purpose — see the DTO comment.
|
||||||
tabs: mapStatusCountsToTabs(statusCounts),
|
tabs: mapStatusCountsToTabs(statusCounts),
|
||||||
|
facets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||||
|
|
||||||
export class BookingListSummaryMetricsDto {
|
export class BookingListSummaryMetricsDto {
|
||||||
@ApiProperty({ example: 42 })
|
@ApiProperty({ example: 42 })
|
||||||
@@ -31,4 +32,18 @@ export class BookingListSummaryDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: BookingListSummaryTabsDto })
|
@ApiProperty({ type: BookingListSummaryTabsDto })
|
||||||
tabs!: BookingListSummaryTabsDto;
|
tabs!: BookingListSummaryTabsDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||||
|
* every OTHER currently-active filter (own predicate omitted per column —
|
||||||
|
* see `BookingsRepository.getFacets`). Unlike `tabs`, which is
|
||||||
|
* deliberately unfiltered so tab counts stay stable while you filter
|
||||||
|
* within a tab, these move with the filter set.
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Facet counts keyed by filter field, for the pill filter bar',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'array', items: { type: 'object' } },
|
||||||
|
})
|
||||||
|
facets!: Record<string, FacetBucket[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
@@ -47,6 +48,8 @@ export interface ContractListFilterOptions {
|
|||||||
hasClearanceDocuments?: boolean;
|
hasClearanceDocuments?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
|
originYardId?: string;
|
||||||
|
destinationYardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -416,14 +419,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return { inQueue, onThisPage, needsAction };
|
return { inQueue, onThisPage, needsAction };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
||||||
|
* own filter doesn't hide its own sibling values (see class doc on
|
||||||
|
* `getFacets`). Every other list/summary/count caller passes nothing.
|
||||||
|
*/
|
||||||
private applyListFilters(
|
private applyListFilters(
|
||||||
qb: SelectQueryBuilder<Contract>,
|
qb: SelectQueryBuilder<Contract>,
|
||||||
options: ContractListFilterOptions,
|
options: ContractListFilterOptions,
|
||||||
|
omit?: keyof ContractListFilterOptions | 'status',
|
||||||
): void {
|
): void {
|
||||||
if (options.statuses?.length) {
|
if (omit !== 'status') {
|
||||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
if (options.statuses?.length) {
|
||||||
} else if (options.status) {
|
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||||
qb.andWhere('contract.status = :status', { status: options.status });
|
} else if (options.status) {
|
||||||
|
qb.andWhere('contract.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (options.companyId) {
|
if (options.companyId) {
|
||||||
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||||
@@ -433,7 +444,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
companyProfileId: options.companyProfileId,
|
companyProfileId: options.companyProfileId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.contractKind) {
|
if (omit !== 'contractKind' && options.contractKind) {
|
||||||
qb.andWhere('contract.contract_kind = :contractKind', {
|
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||||
contractKind: options.contractKind,
|
contractKind: options.contractKind,
|
||||||
});
|
});
|
||||||
@@ -454,20 +465,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
serviceTypeId: options.serviceTypeId,
|
serviceTypeId: options.serviceTypeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.freightType) {
|
if (omit !== 'freightType' && options.freightType) {
|
||||||
qb.andWhere('contract.freight_type = :freightType', {
|
qb.andWhere('contract.freight_type = :freightType', {
|
||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirection) {
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||||
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirections) {
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||||
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||||
}
|
}
|
||||||
if (options.paymentCurrency) {
|
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
|
||||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||||
paymentCurrency: options.paymentCurrency,
|
paymentCurrency: options.paymentCurrency,
|
||||||
});
|
});
|
||||||
@@ -480,6 +491,47 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
if (options.createdTo) {
|
if (options.createdTo) {
|
||||||
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
|
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
|
||||||
}
|
}
|
||||||
|
// Routes are one-to-many (a contract can list several lanes), so origin
|
||||||
|
// and destination each need their own EXISTS — a plain join would
|
||||||
|
// duplicate the contract row per matching route.
|
||||||
|
if (omit !== 'originYardId' && options.originYardId) {
|
||||||
|
qb.andWhere(
|
||||||
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
||||||
|
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
||||||
|
'AND cr_o.origin_yard_id = :originYardId)',
|
||||||
|
{ originYardId: options.originYardId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
||||||
|
qb.andWhere(
|
||||||
|
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
||||||
|
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
||||||
|
'AND cr_d.destination_yard_id = :destinationYardId)',
|
||||||
|
{ destinationYardId: options.destinationYardId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
||||||
|
* column, each with every OTHER active filter applied but its own
|
||||||
|
* predicate omitted — so selecting `status=SUBMITTED` still shows
|
||||||
|
* `APPROVED: 8` in the status popover (to switch), while the freightType
|
||||||
|
* popover reflects only the SUBMITTED-scoped set. Supersedes
|
||||||
|
* `getStatusCounts`, which ignores the active filter entirely.
|
||||||
|
*/
|
||||||
|
async getFacets(options: ContractListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
||||||
|
return computeFacets(
|
||||||
|
() => this.repository.createQueryBuilder('contract').where('contract.deleted_at IS NULL'),
|
||||||
|
(qb, omit) => this.applyListFilters(qb, options, omit as keyof ContractListFilterOptions),
|
||||||
|
{
|
||||||
|
status: 'contract.status',
|
||||||
|
contractKind: 'contract.contract_kind',
|
||||||
|
freightType: 'contract.freight_type',
|
||||||
|
tradeDirection: 'contract.trade_direction',
|
||||||
|
paymentCurrency: 'contract.payment_currency',
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Approval steps ─────────────────────────────────────────────────────────
|
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -768,6 +768,8 @@ export class ContractsService {
|
|||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
originYardId: filter.originYardId,
|
||||||
|
destinationYardId: filter.destinationYardId,
|
||||||
search: filter.search,
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
@@ -789,10 +791,12 @@ export class ContractsService {
|
|||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
originYardId: filter.originYardId,
|
||||||
|
destinationYardId: filter.destinationYardId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [statusCounts, metrics] = await Promise.all([
|
const [facets, metrics] = await Promise.all([
|
||||||
this.contractsRepository.getStatusCounts(),
|
this.contractsRepository.getFacets(listFilter),
|
||||||
this.contractsRepository.getListSummaryMetrics({
|
this.contractsRepository.getListSummaryMetrics({
|
||||||
...listFilter,
|
...listFilter,
|
||||||
page,
|
page,
|
||||||
@@ -801,7 +805,13 @@ export class ContractsService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { metrics, statusCounts };
|
// statusCounts kept for existing callers; now filter-scoped like every
|
||||||
|
// other facet instead of the unfiltered global count `getStatusCounts` gave.
|
||||||
|
const statusCounts = Object.fromEntries(
|
||||||
|
(facets.status ?? []).map((b) => [b.value, b.count]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { metrics, statusCounts, facets };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single contract by ID with relations and signed file URLs. */
|
/** Get a single contract by ID with relations and signed file URLs. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||||
|
|
||||||
export class ContractListSummaryMetricsDto {
|
export class ContractListSummaryMetricsDto {
|
||||||
@ApiProperty({ example: 42 })
|
@ApiProperty({ example: 42 })
|
||||||
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
|
|||||||
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||||
metrics!: ContractListSummaryMetricsDto;
|
metrics!: ContractListSummaryMetricsDto;
|
||||||
|
|
||||||
|
/** @deprecated use `facets.status` — kept for existing callers, computed
|
||||||
|
* from the same filter-scoped query now instead of `getStatusCounts`'s
|
||||||
|
* unfiltered global count. */
|
||||||
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||||
statusCounts!: Record<string, number>;
|
statusCounts!: Record<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||||
|
* every OTHER currently-active filter (each column's own predicate is
|
||||||
|
* omitted from its own count — see `ContractsRepository.getFacets`).
|
||||||
|
* Absent/omitted keys mean the frontend falls back to its static option
|
||||||
|
* list with no counts, never an error.
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Facet counts keyed by filter field, for the pill filter bar',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'array', items: { type: 'object' } },
|
||||||
|
})
|
||||||
|
facets!: Record<string, FacetBucket[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,22 @@ export class FilterContractDto {
|
|||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Only contracts with a route starting at this yard.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Only contracts with a route ending at this yard.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationYardId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@@ -29,52 +29,6 @@ export class DriversRepository extends BaseRepository<Driver> {
|
|||||||
return this.repository.findOne({ where: { id } });
|
return this.repository.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllWithFilters(query: {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}) {
|
|
||||||
const page = query.page || 1;
|
|
||||||
const pageSize = query.pageSize || 10;
|
|
||||||
const skip = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
let queryBuilder = this.repository.createQueryBuilder('driver');
|
|
||||||
|
|
||||||
if (query.search) {
|
|
||||||
queryBuilder = queryBuilder.where(
|
|
||||||
'(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)',
|
|
||||||
{ search: `%${query.search}%` },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.status) {
|
|
||||||
queryBuilder = queryBuilder.andWhere('driver.status = :status', {
|
|
||||||
status: query.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortBy = query.sortBy || 'createdAt';
|
|
||||||
const sortOrder = query.sortOrder || 'DESC';
|
|
||||||
|
|
||||||
queryBuilder = queryBuilder
|
|
||||||
.orderBy(`driver.${sortBy}`, sortOrder)
|
|
||||||
.skip(skip)
|
|
||||||
.take(pageSize);
|
|
||||||
|
|
||||||
const [data, total] = await queryBuilder.getManyAndCount();
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages: Math.ceil(total / pageSize),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createDriver(driverData: any): Promise<Driver> {
|
async createDriver(driverData: any): Promise<Driver> {
|
||||||
const driver = this.repository.create(driverData);
|
const driver = this.repository.create(driverData);
|
||||||
const result = await this.repository.save(driver);
|
const result = await this.repository.save(driver);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
|||||||
/**
|
/**
|
||||||
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
||||||
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
||||||
|
*
|
||||||
|
* `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the
|
||||||
|
* pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself.
|
||||||
*/
|
*/
|
||||||
const build = (
|
const build = (
|
||||||
opts: {
|
opts: {
|
||||||
@@ -19,6 +23,7 @@ const build = (
|
|||||||
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||||
candidate?: { id: string; invoiceNumber: string } | null;
|
candidate?: { id: string; invoiceNumber: string } | null;
|
||||||
register?: jest.Mock;
|
register?: jest.Mock;
|
||||||
|
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const register =
|
const register =
|
||||||
@@ -34,12 +39,17 @@ const build = (
|
|||||||
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const managerUpdate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const managerFindOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow);
|
||||||
|
|
||||||
const service = new EimsAutoSubmitService(
|
const service = new EimsAutoSubmitService(
|
||||||
{ query } as unknown as DataSource,
|
{ query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource,
|
||||||
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
||||||
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
||||||
);
|
);
|
||||||
return { service, register, query };
|
return { service, register, query, managerUpdate, managerFindOne };
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
||||||
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
|
|||||||
expect(register).toHaveBeenCalledTimes(1);
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => {
|
||||||
|
const register = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
|
||||||
|
const { service, managerFindOne, managerUpdate } = build({ candidate, register });
|
||||||
|
|
||||||
|
await expect(service.tick()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(managerFindOne).toHaveBeenCalledTimes(1);
|
||||||
|
expect(managerUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
INVOICE_ID,
|
||||||
|
expect.objectContaining({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsLastError: expect.objectContaining({ message: "no related invoice" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => {
|
||||||
|
const register = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
|
||||||
|
const { service, managerUpdate } = build({
|
||||||
|
candidate,
|
||||||
|
register,
|
||||||
|
managerRow: { eimsStatus: EimsInvoiceStatus.Submitting },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.tick()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(managerUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not start a second tick while one is still filing", async () => {
|
it("does not start a second tick while one is still filing", async () => {
|
||||||
let release: () => void = () => {};
|
let release: () => void = () => {};
|
||||||
const register = jest.fn().mockImplementation(
|
const register = jest.fn().mockImplementation(
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { InjectDataSource } from "@nestjs/typeorm";
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files issued invoices with MoR EIMS on a timer.
|
* Files issued invoices with MoR EIMS on a timer.
|
||||||
@@ -67,21 +69,62 @@ export class EimsAutoSubmitService {
|
|||||||
const candidate = await this.nextCandidate();
|
const candidate = await this.nextCandidate();
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
|
|
||||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
try {
|
||||||
this.logger.log(
|
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
this.logger.log(
|
||||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||||
);
|
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself
|
||||||
|
// (settleFailure) before throwing. A BadRequestException is the one exception: it is only
|
||||||
|
// ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so
|
||||||
|
// nothing is persisted — left alone, this candidate is picked again next tick forever, a
|
||||||
|
// permanent head-of-line block on every invoice behind it. Drain it instead.
|
||||||
|
if (err instanceof BadRequestException) {
|
||||||
|
await this.failStalledCandidate(candidate, err);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
||||||
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
|
// (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate
|
||||||
// next tick at the guard above.
|
// above), and a blocked system number stops the next tick at the guard above.
|
||||||
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the
|
||||||
|
* invoice is still exactly where this tick left it. A reservation's own transactions
|
||||||
|
* (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them,
|
||||||
|
* so the status is re-read fresh rather than trusted from the stale `candidate` row.
|
||||||
|
*/
|
||||||
|
private async failStalledCandidate(
|
||||||
|
candidate: { id: string; invoiceNumber: string },
|
||||||
|
err: BadRequestException,
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } });
|
||||||
|
if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) {
|
||||||
|
this.logger.warn(
|
||||||
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` +
|
||||||
|
`no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() };
|
||||||
|
await this.dataSource.manager.update(Invoice, candidate.id, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsLastError: lastError,
|
||||||
|
} as QueryDeepPartialEntity<Invoice>);
|
||||||
|
this.logger.error(
|
||||||
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Why filing is currently impossible for this system number, or null when it is free. */
|
/** Why filing is currently impossible for this system number, or null when it is free. */
|
||||||
private async systemBlockReason(): Promise<string | null> {
|
private async systemBlockReason(): Promise<string | null> {
|
||||||
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ export interface EimsContextInput {
|
|||||||
session: EimsSessionContext;
|
session: EimsSessionContext;
|
||||||
/** Required when the invoice currency is not ETB. */
|
/** Required when the invoice currency is not ETB. */
|
||||||
exchangeRate?: number | null;
|
exchangeRate?: number | null;
|
||||||
|
/** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */
|
||||||
|
documentType?: EimsMapperContext["documentType"];
|
||||||
|
/** Required (by the mapper) when documentType is DEB/CRE. */
|
||||||
|
reason?: string | null;
|
||||||
|
/** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */
|
||||||
|
relatedDocument?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
||||||
@@ -206,5 +212,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
buyerIdType: invoice.buyerIdType,
|
buyerIdType: invoice.buyerIdType,
|
||||||
buyerIdNumber: invoice.buyerIdNumber,
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
exchangeRate: input.exchangeRate ?? null,
|
exchangeRate: input.exchangeRate ?? null,
|
||||||
|
documentType: input.documentType,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
relatedDocument: input.relatedDocument ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => {
|
||||||
|
const original = invoiceRow({
|
||||||
|
id: "original-invoice",
|
||||||
|
invoiceNumber: "INV-20260807-00001",
|
||||||
|
eimsIrn: IRN,
|
||||||
|
});
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({
|
||||||
|
eimsDocumentType: "CRE",
|
||||||
|
eimsReason: "Overbilled freight charge",
|
||||||
|
relatedInvoice: original,
|
||||||
|
} as Partial<Invoice>),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
|
expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
|
||||||
|
expect(request.ReferenceDetails.RelatedDocument).toBe(IRN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => {
|
||||||
|
const original = invoiceRow({ id: "original-invoice", eimsIrn: null });
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({
|
||||||
|
eimsDocumentType: "DEB",
|
||||||
|
eimsReason: "Additional handling",
|
||||||
|
relatedInvoice: original,
|
||||||
|
} as Partial<Invoice>),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credit/debit note with no related invoice set at all", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial<Invoice>),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("takes SourceSystem from the token session, not from configuration", async () => {
|
it("takes SourceSystem from the token session, not from configuration", async () => {
|
||||||
const db = new FakeDb([invoiceRow()]);
|
const db = new FakeDb([invoiceRow()]);
|
||||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
|||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import {
|
import {
|
||||||
|
EimsDocumentType,
|
||||||
EimsInvoiceRequest,
|
EimsInvoiceRequest,
|
||||||
EimsMapperLine,
|
EimsMapperLine,
|
||||||
toEimsInvoice,
|
toEimsInvoice,
|
||||||
@@ -96,6 +97,27 @@ export class EimsInvoiceRegistrationService {
|
|||||||
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
||||||
if (invoice.eimsIrn) return this.toView(invoice);
|
if (invoice.eimsIrn) return this.toView(invoice);
|
||||||
|
|
||||||
|
// Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason,
|
||||||
|
// ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is
|
||||||
|
// touched — if the original was never actually registered.
|
||||||
|
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
||||||
|
let relatedDocument: string | null = null;
|
||||||
|
if (documentType !== "INV") {
|
||||||
|
if (!invoice.relatedInvoice) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!invoice.relatedInvoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||||
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||||
const session = await this.auth.getSessionContext();
|
const session = await this.auth.getSessionContext();
|
||||||
@@ -114,6 +136,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
invoiceCounter: reservation.invoiceCounter,
|
invoiceCounter: reservation.invoiceCounter,
|
||||||
previousIrn: reservation.previousIrn,
|
previousIrn: reservation.previousIrn,
|
||||||
session,
|
session,
|
||||||
|
documentType,
|
||||||
|
reason: invoice.eimsReason,
|
||||||
|
relatedDocument,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -639,7 +664,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
||||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
relations: { company: true, companyProfile: true },
|
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
||||||
});
|
});
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { sendPdf } from "../billing/billing.controller";
|
||||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
|
|||||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.receipts.listReceipts(id);
|
return this.receipts.listReceipts(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":id/eims/receipts/:receiptId/document")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
|
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
|
||||||
|
async receiptDocument(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("receiptId", ParseUUIDPipe) receiptId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.receipts.document(id, receiptId);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import {
|
||||||
|
InvoiceDocumentModel,
|
||||||
|
pngDataUrl,
|
||||||
|
} from "../billing/documents/invoice-document.service";
|
||||||
|
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors
|
||||||
|
* `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O.
|
||||||
|
*
|
||||||
|
* The amounts (collected amount, mode of payment, withholding amount) live only in
|
||||||
|
* `receipt.request` — the exact body this app sent, typed and written in exactly one place
|
||||||
|
* (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns
|
||||||
|
* would mean a migration + backfill for data already present in a stable shape.
|
||||||
|
*
|
||||||
|
* Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF
|
||||||
|
* for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded
|
||||||
|
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
|
||||||
|
* surface as a 400 — there is nothing sensible to render instead.
|
||||||
|
*/
|
||||||
|
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
|
||||||
|
if (receipt.status !== EimsReceiptStatus.Registered) {
|
||||||
|
throw new Error(
|
||||||
|
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!receipt.request) {
|
||||||
|
throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSales = receipt.kind === "SALES";
|
||||||
|
|
||||||
|
if (isSales) {
|
||||||
|
const req = receipt.request as unknown as EimsSalesReceiptRequest;
|
||||||
|
return build(receipt, invoice, {
|
||||||
|
title: "Sales Receipt",
|
||||||
|
currency: req.ReceiptCurrency,
|
||||||
|
amountLabel: "Collected",
|
||||||
|
lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`,
|
||||||
|
amount: req.CollectedAmount,
|
||||||
|
// A sales receipt is a real payment — this is the one case the shared layout's own default
|
||||||
|
// ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts
|
||||||
|
// if that default changes for an unrelated reason.
|
||||||
|
sealText: "EDR PAID",
|
||||||
|
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = receipt.request as unknown as EimsWithholdReceiptRequest;
|
||||||
|
return build(receipt, invoice, {
|
||||||
|
title: "Withholding Receipt",
|
||||||
|
currency: req.InvoiceDetail.Currency,
|
||||||
|
amountLabel: "Withheld",
|
||||||
|
lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`,
|
||||||
|
amount: req.WithholdDetail.WithholdingAmount,
|
||||||
|
// A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be
|
||||||
|
// wrong here, so this is the one case that MUST override it.
|
||||||
|
sealText: "EDR",
|
||||||
|
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(
|
||||||
|
receipt: EimsReceipt,
|
||||||
|
invoice: Invoice,
|
||||||
|
opts: {
|
||||||
|
title: string;
|
||||||
|
currency: string;
|
||||||
|
amountLabel: string;
|
||||||
|
lineDescription: string;
|
||||||
|
amount: number;
|
||||||
|
sealText: string;
|
||||||
|
extraSummary: Array<{ label: string; value: string | null }>;
|
||||||
|
},
|
||||||
|
): InvoiceDocumentModel {
|
||||||
|
return {
|
||||||
|
kind: "RECEIPT",
|
||||||
|
title: opts.title,
|
||||||
|
documentNumber: receipt.receiptNumber,
|
||||||
|
issuedAt: receipt.submittedAt ?? null,
|
||||||
|
status: receipt.status,
|
||||||
|
currency: opts.currency,
|
||||||
|
summary: [
|
||||||
|
{ label: "Invoice", value: invoice.invoiceNumber },
|
||||||
|
{ label: "Invoice IRN", value: invoice.eimsIrn ?? null },
|
||||||
|
{ label: "RRN", value: receipt.rrn ?? null },
|
||||||
|
{ label: "Ack status", value: receipt.ackStatus ?? null },
|
||||||
|
...opts.extraSummary,
|
||||||
|
],
|
||||||
|
// No line items on a receipt — one synthetic line, since buildHtml renders the line table
|
||||||
|
// unconditionally and an empty `lines: []` would print a header-only empty table.
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
description: opts.lineDescription,
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: opts.amount,
|
||||||
|
amount: opts.amount,
|
||||||
|
currency: opts.currency,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }],
|
||||||
|
sealText: opts.sealText,
|
||||||
|
qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
@@ -40,10 +41,16 @@ class FakeDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private manager = {
|
private manager = {
|
||||||
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
findOne: async (
|
||||||
entity === Invoice
|
entity: unknown,
|
||||||
? (this.invoices.get(options.where.id) ?? null)
|
options: { where: { id?: string; invoiceId?: string } },
|
||||||
: (this.receipts.get(options.where.id) ?? null),
|
) => {
|
||||||
|
if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null;
|
||||||
|
const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined;
|
||||||
|
if (!receipt) return null;
|
||||||
|
if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null;
|
||||||
|
return receipt;
|
||||||
|
},
|
||||||
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
||||||
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||||
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||||
@@ -71,6 +78,7 @@ const build = (
|
|||||||
db: FakeDb,
|
db: FakeDb,
|
||||||
postBearer: jest.Mock,
|
postBearer: jest.Mock,
|
||||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
documents: { render: jest.Mock } = { render: jest.fn() },
|
||||||
) =>
|
) =>
|
||||||
new EimsReceiptService(
|
new EimsReceiptService(
|
||||||
db.asDataSource(),
|
db.asDataSource(),
|
||||||
@@ -78,6 +86,7 @@ const build = (
|
|||||||
{ postBearer } as unknown as EimsClientService,
|
{ postBearer } as unknown as EimsClientService,
|
||||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||||
{ directSend } as unknown as NotificationsService,
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
documents as unknown as InvoiceDocumentService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const okResponse = (over: Record<string, unknown> = {}) => ({
|
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||||
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
|
|||||||
expect(list).toHaveLength(2);
|
expect(list).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.document", () => {
|
||||||
|
it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||||
|
const receipt = await service.registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
collectedAmount: 500,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.document(INVOICE_ID, receipt.id);
|
||||||
|
|
||||||
|
expect(documents.render).toHaveBeenCalledTimes(1);
|
||||||
|
const model = documents.render.mock.calls[0][0];
|
||||||
|
expect(model.kind).toBe("RECEIPT");
|
||||||
|
expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo...");
|
||||||
|
expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" });
|
||||||
|
expect(model.lines[0].amount).toBe(500);
|
||||||
|
expect(model.sealText).toBe("EDR PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||||
|
const receipt = await service.registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 1000,
|
||||||
|
withholdingAmount: 20,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.document(INVOICE_ID, receipt.id);
|
||||||
|
|
||||||
|
const model = documents.render.mock.calls[0][0];
|
||||||
|
expect(model.lines[0].amount).toBe(20);
|
||||||
|
expect(model.sealText).toBe("EDR");
|
||||||
|
expect(model.sealText).not.toContain("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to render a receipt that was never acknowledged by MoR", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
|
||||||
|
const documents = { render: jest.fn() };
|
||||||
|
const service = build(db, postBearer, undefined, documents);
|
||||||
|
await expect(
|
||||||
|
service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
|
||||||
|
await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(documents.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => {
|
||||||
|
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()));
|
||||||
|
const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
|
||||||
|
await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
|
||||||
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
@@ -52,6 +54,7 @@ export class EimsReceiptService {
|
|||||||
private readonly client: EimsClientService,
|
private readonly client: EimsClientService,
|
||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly documents: InvoiceDocumentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -154,6 +157,31 @@ export class EimsReceiptService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on
|
||||||
|
* `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a
|
||||||
|
* sealed tax document, and `toReceiptDocumentModel` is the one place that guards it.
|
||||||
|
*/
|
||||||
|
async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const receipt = await this.dataSource.manager.findOne(EimsReceipt, {
|
||||||
|
where: { id: receiptId, invoiceId },
|
||||||
|
});
|
||||||
|
if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`);
|
||||||
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
|
||||||
|
let model: ReturnType<typeof toReceiptDocumentModel>;
|
||||||
|
try {
|
||||||
|
model = toReceiptDocumentModel(receipt, invoice);
|
||||||
|
} catch (err) {
|
||||||
|
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
|
||||||
|
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
|
||||||
|
// itself throws.
|
||||||
|
throw new BadRequestException((err as Error).message);
|
||||||
|
}
|
||||||
|
return this.documents.render(model);
|
||||||
|
}
|
||||||
|
|
||||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async submit(
|
private async submit(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
||||||
|
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
||||||
|
DocumentsModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
|
|||||||
|
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a code goes. At least one of phone/email must be set — enforced by the
|
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||||
@@ -146,6 +147,21 @@ export class OtpService {
|
|||||||
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dev/staging only: the row above still exists (so a real code would
|
||||||
|
// still verify), but skip the real SMS/email send — no carrier cost, no
|
||||||
|
// dependency on RabbitMQ/the mail relay being up. Verify with the fixed
|
||||||
|
// DEV_BYPASS_OTP code instead of whatever landed in the row.
|
||||||
|
if (isBypassEnv()) {
|
||||||
|
this.logger.warn(
|
||||||
|
`otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
delivered: true,
|
||||||
|
message: "OTP sent successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||||
// here let an attacker wipe the per-target guess budget just by calling
|
// here let an attacker wipe the per-target guess budget just by calling
|
||||||
// /otp/send between guesses. The counter is cleared only when the code is
|
// /otp/send between guesses. The counter is cleared only when the code is
|
||||||
@@ -394,7 +410,10 @@ export class OtpService {
|
|||||||
|
|
||||||
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
||||||
// brute-forced within its TTL; the code is burned once the budget is spent.
|
// brute-forced within its TTL; the code is burned once the budget is spent.
|
||||||
if (otpData.otp !== otp) {
|
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||||
|
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||||
|
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||||
|
if (otpData.otp !== otp && !bypassed) {
|
||||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
@@ -482,7 +501,10 @@ export class OtpService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otpData.otp !== otp) {
|
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||||
|
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||||
|
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||||
|
if (otpData.otp !== otp && !bypassed) {
|
||||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||||
|
|
||||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
IntentStatusDto,
|
IntentStatusDto,
|
||||||
PaymentPlatformDto,
|
PaymentPlatformDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
|
import { isBypassEnv } from "../../common/dev-bypass.util";
|
||||||
|
|
||||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||||
* the caller (billing) — this service never derives them from a domain record. */
|
* the caller (billing) — this service never derives them from a domain record. */
|
||||||
@@ -256,34 +257,52 @@ export class PaymentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
// Dev/staging only: skip the real gateway call entirely and report an
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
// billing notify) runs exactly as it would for a real synchronous
|
||||||
referenceId: input.referenceId,
|
// provider success.
|
||||||
orderRef: input.orderRef,
|
const snapshot: PaymentIntentSnapshot = isBypassEnv()
|
||||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
? {
|
||||||
// debited against the intent amount, so the dev shortcut would break it.
|
intentId: `bypass-${input.referenceId}`,
|
||||||
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
// shortcut floor is 10, not 1.
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
// amountMinor: isCbeBill
|
referenceId: input.referenceId,
|
||||||
// ? input.amountMinor
|
merchantOrderId: input.orderRef,
|
||||||
// : input.method === ProviderMethod.CAC_BANK
|
provider: input.method as ProviderMethod,
|
||||||
// ? 10
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
// : 1,
|
amountMinor: input.amountMinor,
|
||||||
amountMinor: input.amountMinor,
|
currency: input.currency,
|
||||||
currency: input.currency,
|
providerTxnId: `bypass-${input.referenceId}`,
|
||||||
provider: input.method as ProviderMethod,
|
paidAt: new Date().toISOString(),
|
||||||
platform: input.platform,
|
}
|
||||||
payerAccount: input.payerAccount,
|
: await this.paymentClient.initiate({
|
||||||
payerName: input.payerName,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
expiresAt: input.expiresAt,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
referenceId: input.referenceId,
|
||||||
returnUrl:
|
orderRef: input.orderRef,
|
||||||
input.returnUrl ??
|
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||||
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
// debited against the intent amount, so the dev shortcut would break it.
|
||||||
failureUrl:
|
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
// shortcut floor is 10, not 1.
|
||||||
});
|
// amountMinor: isCbeBill
|
||||||
|
// ? input.amountMinor
|
||||||
|
// : input.method === ProviderMethod.CAC_BANK
|
||||||
|
// ? 10
|
||||||
|
// : 1,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency,
|
||||||
|
provider: input.method as ProviderMethod,
|
||||||
|
platform: input.platform,
|
||||||
|
payerAccount: input.payerAccount,
|
||||||
|
payerName: input.payerName,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
||||||
|
returnUrl:
|
||||||
|
input.returnUrl ??
|
||||||
|
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
||||||
|
failureUrl:
|
||||||
|
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||||
|
});
|
||||||
|
|
||||||
const immediateSuccess =
|
const immediateSuccess =
|
||||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||||
|
|||||||
@@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
|
|||||||
return this.repository.findOne({ where: { id } });
|
return this.repository.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllWithFilters(query: {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}) {
|
|
||||||
const page = query.page || 1;
|
|
||||||
const pageSize = query.pageSize || 10;
|
|
||||||
const skip = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
let queryBuilder = this.repository.createQueryBuilder('vehicle');
|
|
||||||
|
|
||||||
if (query.search) {
|
|
||||||
queryBuilder = queryBuilder.where(
|
|
||||||
'(vehicle.plateNumber ILIKE :search OR vehicle.manufacturer ILIKE :search OR vehicle.model ILIKE :search)',
|
|
||||||
{ search: `%${query.search}%` },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.status) {
|
|
||||||
queryBuilder = queryBuilder.andWhere('vehicle.status = :status', {
|
|
||||||
status: query.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortBy = query.sortBy || 'createdAt';
|
|
||||||
const sortOrder = query.sortOrder || 'DESC';
|
|
||||||
|
|
||||||
queryBuilder = queryBuilder
|
|
||||||
.orderBy(`vehicle.${sortBy}`, sortOrder)
|
|
||||||
.skip(skip)
|
|
||||||
.take(pageSize);
|
|
||||||
|
|
||||||
const [data, total] = await queryBuilder.getManyAndCount();
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages: Math.ceil(total / pageSize),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
||||||
const vehicle = this.repository.create(vehicleData);
|
const vehicle = this.repository.create(vehicleData);
|
||||||
return this.repository.save(vehicle);
|
return this.repository.save(vehicle);
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import {
|
|||||||
NormalizedFaydaUserInfo,
|
NormalizedFaydaUserInfo,
|
||||||
VerifaydaPurpose,
|
VerifaydaPurpose,
|
||||||
} from './verifayda.types';
|
} from './verifayda.types';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isBypassEnv } from '../../common/dev-bypass.util';
|
||||||
|
|
||||||
|
/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */
|
||||||
|
export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS';
|
||||||
|
|
||||||
export interface StartVerificationInput {
|
export interface StartVerificationInput {
|
||||||
purpose: VerifaydaPurpose;
|
purpose: VerifaydaPurpose;
|
||||||
@@ -143,6 +148,26 @@ export class VerifaydaService {
|
|||||||
async completeVerification(
|
async completeVerification(
|
||||||
query: VerifaydaCallbackDto,
|
query: VerifaydaCallbackDto,
|
||||||
): Promise<CompleteVerificationResult> {
|
): Promise<CompleteVerificationResult> {
|
||||||
|
// Dev/staging only: caller sends the sentinel code instead of a real
|
||||||
|
// eSignet redirect — skip the token exchange/session entirely and hand
|
||||||
|
// back a canned VERIFY result. `sub` is unique per call so binding both
|
||||||
|
// owner and PoA in the same bypass session doesn't collide.
|
||||||
|
if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) {
|
||||||
|
this.logger.warn('Fayda verification BYPASSED (dev/staging)');
|
||||||
|
return {
|
||||||
|
purpose: 'VERIFY',
|
||||||
|
verified: true,
|
||||||
|
sub: `dev-bypass-${randomUUID()}`,
|
||||||
|
fullName: 'Dev Bypass User',
|
||||||
|
email: 'dev-bypass@example.com',
|
||||||
|
phoneNumber: '+251900000000',
|
||||||
|
birthdate: '1990-01-01',
|
||||||
|
gender: 'M',
|
||||||
|
address: 'Dev Bypass Address',
|
||||||
|
userDataSaved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (query.error) {
|
if (query.error) {
|
||||||
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
||||||
if (query.state) {
|
if (query.state) {
|
||||||
|
|||||||
@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:eims_receipt_register",
|
"edr_freight_app:invoices:eims_receipt_register",
|
||||||
"Register a sales or withholding receipt with MoR EIMS",
|
"Register a sales or withholding receipt with MoR EIMS",
|
||||||
),
|
),
|
||||||
|
// Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any
|
||||||
|
// other issued invoice — so it carries the same restricted grant as the eims_* actions above,
|
||||||
|
// not invoices:export.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-00000000000a",
|
||||||
|
"edr_freight_app:invoices:memo_issue",
|
||||||
|
"Issue a credit or debit memo against a registered invoice",
|
||||||
|
),
|
||||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||||
perm(
|
perm(
|
||||||
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
|
|||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||||
|
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
@@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||||
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
||||||
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
||||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
// endpoints, and stay off the general Finance role. They are granted to the `chief` position
|
||||||
|
// instead — see below — the same maker–checker split already used for shipping-line credit
|
||||||
|
// mark-paid/cancel (Finance raises, chief decides).
|
||||||
FREIGHT_PERMS.payments.view,
|
FREIGHT_PERMS.payments.view,
|
||||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
// Shipping-line credit ledger is a Finance surface: bill batches into
|
// Shipping-line credit ledger is a Finance surface: bill batches into
|
||||||
@@ -2519,6 +2530,14 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.governmentExpedite,
|
FREIGHT_PERMS.bookings.governmentExpedite,
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
|
// Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role
|
||||||
|
// (see that preset's comment) and granted here instead — the chief is already the decision
|
||||||
|
// side of every other sensitive finance action (mark-paid/cancel approval below), and these
|
||||||
|
// are irreversible-at-MoR or receivable-creating in the same way.
|
||||||
|
FREIGHT_PERMS.invoices.eimsCancel,
|
||||||
|
FREIGHT_PERMS.invoices.eimsResolve,
|
||||||
|
FREIGHT_PERMS.invoices.eimsReceiptRegister,
|
||||||
|
FREIGHT_PERMS.invoices.memoIssue,
|
||||||
FREIGHT_PERMS.payments.view,
|
FREIGHT_PERMS.payments.view,
|
||||||
// Decision side of the credit-invoice two-step: finance raises
|
// Decision side of the credit-invoice two-step: finance raises
|
||||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Anchor, Divider, Group, TextInput } from "@mantine/core";
|
||||||
|
import { Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef, SortOption } from "./types";
|
||||||
|
import type { UseFilters } from "./useFilters";
|
||||||
|
import { FilterPill } from "./FilterPill";
|
||||||
|
import { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||||
|
import { SaveViewButton } from "./SaveViewButton";
|
||||||
|
import { SavedViewCards } from "./SavedViewCards";
|
||||||
|
import { SortControl } from "./SortControl";
|
||||||
|
import { useSavedViews } from "./useSavedViews";
|
||||||
|
|
||||||
|
export interface FilterBarProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
controls: UseFilters;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
showSearch?: boolean;
|
||||||
|
/** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */
|
||||||
|
sortOptions?: SortOption[];
|
||||||
|
/** localStorage namespace for saved views. Omit to hide the control. */
|
||||||
|
viewId?: string;
|
||||||
|
/** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
defs,
|
||||||
|
controls,
|
||||||
|
searchPlaceholder = "Search…",
|
||||||
|
showSearch = true,
|
||||||
|
sortOptions,
|
||||||
|
viewId,
|
||||||
|
children,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
// Filters just picked from "More filters" render as an already-open pill
|
||||||
|
// until the popover closes, then fall back to the ordinary pinned/active split.
|
||||||
|
const [justPicked, setJustPicked] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key));
|
||||||
|
const secondary = defs.filter((d) => !pinned.includes(d));
|
||||||
|
// Applied filters read first, left to right — a stable partition keeps
|
||||||
|
// each group in its original def order rather than resorting on every apply.
|
||||||
|
const orderedPinned = [
|
||||||
|
...pinned.filter((d) => controls.values[d.key]),
|
||||||
|
...pinned.filter((d) => !controls.values[d.key]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Unconditional call (rules of hooks) — viewId is a per-page constant, and
|
||||||
|
// the hook is a no-op storage key when saved views aren't wired up.
|
||||||
|
const savedViews = useSavedViews(viewId ?? "__unset__");
|
||||||
|
const activeQuery = controls.currentQueryString();
|
||||||
|
const hasMatchingView = savedViews.views.some((v) => v.query === activeQuery);
|
||||||
|
const canSaveView = Boolean(viewId) && activeQuery.length > 0 && !hasMatchingView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{viewId && (
|
||||||
|
<SavedViewCards
|
||||||
|
defs={defs}
|
||||||
|
views={savedViews.views}
|
||||||
|
activeQuery={activeQuery}
|
||||||
|
applyQueryString={controls.applyQueryString}
|
||||||
|
onRemove={savedViews.remove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Two independent zones on wide screens — left (search + pills + more
|
||||||
|
filters + clear) wraps to as many lines as it needs, right (sort +
|
||||||
|
save) stays pinned on the first line via `sm:flex-nowrap` +
|
||||||
|
`sm:shrink-0`. `nowrap` unconditionally (the old inline style) forced
|
||||||
|
that same two-column layout on a phone too: neither zone had room and
|
||||||
|
both got squeezed/clipped. Below the `sm` breakpoint this stacks to a
|
||||||
|
single column instead — full-width left row, full-width right row.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:flex-nowrap items-start gap-2">
|
||||||
|
<Group gap="xs" wrap="wrap" align="center" className="flex-1 min-w-0 w-full">
|
||||||
|
{showSearch && (
|
||||||
|
<TextInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={controls.searchText}
|
||||||
|
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
||||||
|
size="xs"
|
||||||
|
radius="lg"
|
||||||
|
// Regular weight (not the Button-driven 600 the rest of the bar
|
||||||
|
// uses) and a solid, fully-opaque border/text — same "opaque, not
|
||||||
|
// faint" fix the inactive pill trigger got.
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
fontWeight: 400,
|
||||||
|
borderColor: "var(--mantine-color-gray-6)",
|
||||||
|
color: "var(--mantine-color-gray-9)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ minWidth: 160, flex: "1 1 160px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderedPinned.map((def) => (
|
||||||
|
<FilterPill
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
value={controls.values[def.key]}
|
||||||
|
onChange={(v) => controls.setFilter(def.key, v)}
|
||||||
|
autoOpen={justPicked.includes(def.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MoreFiltersMenu
|
||||||
|
defs={secondary}
|
||||||
|
onPick={(key) => setJustPicked((prev) => [...prev, key])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{controls.activeCount > 0 && (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
c="red.6"
|
||||||
|
underline="never"
|
||||||
|
onClick={() => {
|
||||||
|
controls.clearFilters();
|
||||||
|
setJustPicked([]);
|
||||||
|
}}
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Clear
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Sorting is a different kind of control (view order, not scope) —
|
||||||
|
cut off from the filter pills by a vertical divider and pinned to
|
||||||
|
the right, independent of how the left side wraps. */}
|
||||||
|
{/*
|
||||||
|
Plain div, not <Group>: Group's `wrap` prop sets an inline
|
||||||
|
flex-wrap style, which always beats a Tailwind class regardless of
|
||||||
|
breakpoint — `sm:flex-nowrap` would never win against `wrap="wrap"`.
|
||||||
|
Wrap on mobile (own row, room is tight), pinned nowrap from `sm` up.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2 shrink-0">
|
||||||
|
{children}
|
||||||
|
{sortOptions && sortOptions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<SortControl options={sortOptions} value={controls.sort} onChange={controls.setSort} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canSaveView && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<SaveViewButton defs={defs} query={activeQuery} onSave={savedViews.save} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ActionIcon, Button, Popover } from "@mantine/core";
|
||||||
|
import { ChevronDown, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import { formatFilterValue } from "./format";
|
||||||
|
import { BooleanBody } from "./bodies/BooleanBody";
|
||||||
|
import { DateBody } from "./bodies/DateBody";
|
||||||
|
import { EnumBody } from "./bodies/EnumBody";
|
||||||
|
import { NumberBody } from "./bodies/NumberBody";
|
||||||
|
import { RouteBody } from "./bodies/RouteBody";
|
||||||
|
import { TextBody } from "./bodies/TextBody";
|
||||||
|
|
||||||
|
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||||
|
text: TextBody,
|
||||||
|
enum: EnumBody,
|
||||||
|
date: DateBody,
|
||||||
|
number: NumberBody,
|
||||||
|
boolean: BooleanBody,
|
||||||
|
route: RouteBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Most bodies fit a narrow popover; a date range needs room for the presets
|
||||||
|
// sidebar next to the calendar, so it gets a wider minimum.
|
||||||
|
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 };
|
||||||
|
|
||||||
|
export interface FilterPillProps {
|
||||||
|
def: FilterDef;
|
||||||
|
value: FilterValue | undefined;
|
||||||
|
onChange: (v: FilterValue | undefined) => void;
|
||||||
|
/** Opened immediately (used when picked from "More filters"). */
|
||||||
|
autoOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) {
|
||||||
|
const [opened, setOpened] = useState(Boolean(autoOpen));
|
||||||
|
const Body = BODIES[def.type];
|
||||||
|
const active = Boolean(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
// Inactive: styled like a closed Mantine Select trigger — solid
|
||||||
|
// (opaque, not dashed) border, label + trailing chevron, no leading
|
||||||
|
// "+" — just a smaller/pill-shaped version of that same control.
|
||||||
|
// Active: "light" (soft tinted fill), not "filled" — a whole row of
|
||||||
|
// solid green buttons was the "too loud" complaint; light keeps the
|
||||||
|
// active/inactive contrast without shouting. Popover side/position
|
||||||
|
// is untouched either way.
|
||||||
|
variant={active ? "light" : "default"}
|
||||||
|
color={active ? "edr-green" : undefined}
|
||||||
|
styles={
|
||||||
|
active
|
||||||
|
? undefined
|
||||||
|
: { root: { borderColor: "var(--mantine-color-gray-6)", color: "var(--mantine-color-gray-9)" } }
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
active ? (
|
||||||
|
<ActionIcon
|
||||||
|
component="span"
|
||||||
|
size={22}
|
||||||
|
radius="xl"
|
||||||
|
variant="subtle"
|
||||||
|
color="edr-green"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
>
|
||||||
|
{active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label}
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown miw={DROPDOWN_WIDTH[def.type] ?? 260} p="xs">
|
||||||
|
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { Plus, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef } from "./types";
|
||||||
|
|
||||||
|
export interface MoreFiltersMenuProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
/** Called with the picked def's key — the caller pins it and opens its popover. */
|
||||||
|
onPick: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searchable list over the page's secondary/inactive filters. Plain filter + list,
|
||||||
|
* not cmdk — a handful of static strings doesn't need a Combobox store. */
|
||||||
|
export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())),
|
||||||
|
[defs, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (defs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
// "default" (opaque border + solid text), not "outline" (faint
|
||||||
|
// color-tinted border/text) — same fix as the inactive filter pill.
|
||||||
|
variant="default"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
>
|
||||||
|
More filters
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown miw={220} p="xs">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search filters…"
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah={280}>
|
||||||
|
<Stack gap={2}>
|
||||||
|
{visible.map((d) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={d.key}
|
||||||
|
px="xs"
|
||||||
|
py={6}
|
||||||
|
className="hover:bg-gray-100 transition-colors"
|
||||||
|
style={{ borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
onClick={() => {
|
||||||
|
setOpened(false);
|
||||||
|
setQuery("");
|
||||||
|
onPick(d.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="text-[var(--mantine-color-edr-green-6)]" />
|
||||||
|
<Text size="sm" c="edr-green.7">
|
||||||
|
{d.label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed" px="xs" py={6}>
|
||||||
|
No matching filters
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SegmentedControl } from "@mantine/core";
|
||||||
|
import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types";
|
||||||
|
|
||||||
|
export interface OperatorSelectProps {
|
||||||
|
def: FilterDef;
|
||||||
|
value: Operator;
|
||||||
|
onChange: (op: Operator) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware
|
||||||
|
* operators are a capability, not a dropdown forced into every popover. */
|
||||||
|
export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) {
|
||||||
|
const operators = def.operators ?? [DEFAULT_OP[def.type]];
|
||||||
|
if (operators.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v as Operator)}
|
||||||
|
data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))}
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { Check, Save } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { FilterDef } from "./types";
|
||||||
|
import { describeQuery } from "./format";
|
||||||
|
import type { SavedView } from "./useSavedViews";
|
||||||
|
|
||||||
|
export interface SaveViewButtonProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
query: string;
|
||||||
|
onSave: (query: string) => SavedView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filled, not outline — this is the one action-y button in the bar (every
|
||||||
|
* other control here is a filter), so it needs to actually look like a
|
||||||
|
* button. One click, no name prompt: the card grid's label is generated
|
||||||
|
* from the active filters (see `describeQuery`). */
|
||||||
|
export function SaveViewButton({ defs, query, onSave }: SaveViewButtonProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(query);
|
||||||
|
toast({ title: "View saved", description: describeQuery(defs, query), duration: 4000 });
|
||||||
|
// The toast is in the corner; this flash is right where the eye already
|
||||||
|
// is — the actual confirmation that "the saving" registered.
|
||||||
|
setJustSaved(true);
|
||||||
|
setTimeout(() => setJustSaved(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
color={justSaved ? "teal" : "edr-green"}
|
||||||
|
leftSection={justSaved ? <Check size={15} /> : <Save size={15} />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={justSaved}
|
||||||
|
>
|
||||||
|
{justSaved ? "Saved" : "Save"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ActionIcon, Card, SimpleGrid, Text } from "@mantine/core";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { FilterDef } from "./types";
|
||||||
|
import { describeQuery } from "./format";
|
||||||
|
import type { SavedView } from "./useSavedViews";
|
||||||
|
|
||||||
|
export interface SavedViewCardsProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
views: SavedView[];
|
||||||
|
activeQuery: string;
|
||||||
|
applyQueryString: (query: string) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved views up front as a grid of cards — not one more item buried in a
|
||||||
|
* dropdown nobody opens. Renders nothing until there's at least one saved. */
|
||||||
|
export function SavedViewCards({ defs, views, activeQuery, applyQueryString, onRemove }: SavedViewCardsProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
if (views.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// base: 1 — a phone-width viewport forcing 2 columns is what clipped
|
||||||
|
// card text and overflowed the row; one full-width card per row until
|
||||||
|
// there's actually room for more.
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, md: 4, lg: 5 }} spacing="xs" mb="sm">
|
||||||
|
{views.map((v) => {
|
||||||
|
const active = v.query === activeQuery;
|
||||||
|
const label = describeQuery(defs, v.query);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={v.id}
|
||||||
|
withBorder
|
||||||
|
padding="xs"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => {
|
||||||
|
applyQueryString(v.query);
|
||||||
|
toast({ title: `Switched to "${label}"` });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: active ? "var(--mantine-color-edr-green-6)" : undefined,
|
||||||
|
borderWidth: active ? 2 : 1,
|
||||||
|
backgroundColor: active ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 6 }}>
|
||||||
|
<Text size="xs" fw={500} lineClamp={2} style={{ flex: 1 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(v.id);
|
||||||
|
toast({ title: "View deleted", description: label, variant: "destructive" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Button, Menu } from "@mantine/core";
|
||||||
|
import { ArrowUpDown, Check } from "lucide-react";
|
||||||
|
|
||||||
|
import type { SortOption } from "./types";
|
||||||
|
|
||||||
|
export interface SortControlProps {
|
||||||
|
options: SortOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A control, not a form field — Menu (not Select) gives the check-mark +
|
||||||
|
* trigger-label read Stripe's sort control has. Rendered only when a page
|
||||||
|
* passes sortOptions; inventing options for an endpoint without sortBy
|
||||||
|
* support would ship a control that silently does nothing. */
|
||||||
|
export function SortControl({ options, value, onChange }: SortControlProps) {
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
const current = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu position="bottom-end" withinPortal shadow="md">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button size="xs" variant="outline" color="gray" leftSection={<ArrowUpDown size={14} />}>
|
||||||
|
{current?.label ?? "Sort"}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={o.value}
|
||||||
|
leftSection={o.value === value ? <Check size={14} /> : <span style={{ width: 14 }} />}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Radio, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { BooleanFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<BooleanFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
|
||||||
|
const [v, setV] = useState(value?.v[0] ?? "");
|
||||||
|
|
||||||
|
// Two mutually-exclusive options — apply the moment one is picked, same as
|
||||||
|
// EnumBody's single-select radio. No Apply button needed.
|
||||||
|
const pick = (next: string) => {
|
||||||
|
setV(next);
|
||||||
|
onChange({ op, v: [next] });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
<Radio.Group value={v} onChange={pick}>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
|
||||||
|
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
import { CalendarDays } from "lucide-react";
|
||||||
|
|
||||||
|
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||||
|
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { DateFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
// ponytail: Gregorian only. Record-management pages need the Ethiopian
|
||||||
|
// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an
|
||||||
|
// i18n.language !== "en" branch here when this body is first wired into a
|
||||||
|
// record-management page (Phase 4 of the filter-bar rollout).
|
||||||
|
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||||
|
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||||
|
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||||
|
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (op === "between") {
|
||||||
|
onChange(
|
||||||
|
from && to
|
||||||
|
? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onChange(
|
||||||
|
from
|
||||||
|
? {
|
||||||
|
op,
|
||||||
|
v: [
|
||||||
|
op === "before"
|
||||||
|
? startOfDayIso(parseDateStr(from))
|
||||||
|
: endOfDayIso(parseDateStr(from)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// This popover already lives inside FilterPill's own Popover. Mantine's
|
||||||
|
// DatePickerInput opens ITS calendar in a separate portal by default, so a
|
||||||
|
// click on a day registers as "outside" the outer Popover and closes the
|
||||||
|
// whole filter before the range can be picked (or Apply reached) — the
|
||||||
|
// reported "date picker doesn't work". Keeping the calendar un-portalled
|
||||||
|
// renders it inside the outer popover's own DOM subtree instead, so
|
||||||
|
// outside-click detection sees it as inside.
|
||||||
|
const nestedPopoverProps = { withinPortal: false } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{op === "between" ? (
|
||||||
|
<DatePickerInput
|
||||||
|
type="range"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<CalendarDays size={14} />}
|
||||||
|
placeholder="Any"
|
||||||
|
value={[from, to]}
|
||||||
|
onChange={([f, t]) => {
|
||||||
|
setFrom(f);
|
||||||
|
setTo(t);
|
||||||
|
}}
|
||||||
|
presets={getDateRangePresets()}
|
||||||
|
popoverProps={nestedPopoverProps}
|
||||||
|
clearable
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DatePickerInput
|
||||||
|
size="sm"
|
||||||
|
leftSection={<CalendarDays size={14} />}
|
||||||
|
placeholder="Any"
|
||||||
|
value={from}
|
||||||
|
onChange={setFrom}
|
||||||
|
popoverProps={nestedPopoverProps}
|
||||||
|
clearable
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={apply} disabled={op === "between" ? !(from && to) : !from}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { EnumFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
/** How many options before a search box appears above the list. */
|
||||||
|
const SEARCH_THRESHOLD = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stretches the Checkbox/Radio's native <label> across the full popover
|
||||||
|
* width and pads it, so the clickable/tappable area is the whole row —
|
||||||
|
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
|
||||||
|
* are Mantine's part names for this; `cursor: pointer` on the row (not just
|
||||||
|
* the input) makes the affordance visible before you even click.
|
||||||
|
*/
|
||||||
|
const ROW_STYLES = {
|
||||||
|
root: { padding: "10px 10px", borderRadius: 6 },
|
||||||
|
body: { alignItems: "center" as const },
|
||||||
|
labelWrapper: { flex: 1 },
|
||||||
|
label: { cursor: "pointer", paddingLeft: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function OptionLabel({ label, count }: { label: string; count?: number }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Text size="sm">{label}</Text>
|
||||||
|
{count !== undefined && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<EnumFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.enum);
|
||||||
|
const [selected, setSelected] = useState<string[]>(value?.v ?? []);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const multiple = def.multiple ?? true;
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const byQuery = query
|
||||||
|
? def.options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: def.options;
|
||||||
|
if (!def.counts || showAll) return byQuery;
|
||||||
|
// Hide zero-count options, but never hide one the user already picked —
|
||||||
|
// otherwise a filter that narrows to zero rows becomes impossible to un-select.
|
||||||
|
return byQuery.filter((o) => (def.counts![o.value] ?? 0) > 0 || selected.includes(o.value));
|
||||||
|
}, [def.options, def.counts, query, showAll, selected]);
|
||||||
|
|
||||||
|
const hiddenCount = def.options.length - visible.length;
|
||||||
|
|
||||||
|
const apply = (v: string[] = selected) => {
|
||||||
|
onChange(v.length ? { op, v } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-select is a radio pick, not a build-up-a-set gesture — apply the
|
||||||
|
// instant one is chosen, same as picking an option in a plain Select.
|
||||||
|
// Checkbox (multiple) still needs the explicit Apply: picking several
|
||||||
|
// options is a multi-step gesture the popover shouldn't close mid-way through.
|
||||||
|
const applyRadio = (v: string) => {
|
||||||
|
setSelected([v]);
|
||||||
|
apply([v]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{def.options.length > SEARCH_THRESHOLD && (
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search options…"
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack gap={0} mah={260} style={{ overflowY: "auto" }}>
|
||||||
|
{multiple ? (
|
||||||
|
<Checkbox.Group value={selected} onChange={setSelected} aria-label={`Filter by ${def.label}`}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visible.map((o) => (
|
||||||
|
<Checkbox
|
||||||
|
key={o.value}
|
||||||
|
value={o.value}
|
||||||
|
size="sm"
|
||||||
|
// The count sits INSIDE the label, so it's part of the
|
||||||
|
// native <label> the input is bound to — clicking it (not
|
||||||
|
// just the tiny checkbox square) toggles the option too.
|
||||||
|
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||||
|
styles={ROW_STYLES}
|
||||||
|
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Checkbox.Group>
|
||||||
|
) : (
|
||||||
|
<Radio.Group
|
||||||
|
value={selected[0] ?? ""}
|
||||||
|
onChange={(v) => v && applyRadio(v)}
|
||||||
|
aria-label={`Filter by ${def.label}`}
|
||||||
|
>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visible.map((o) => (
|
||||||
|
<Radio
|
||||||
|
key={o.value}
|
||||||
|
value={o.value}
|
||||||
|
size="sm"
|
||||||
|
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||||
|
styles={ROW_STYLES}
|
||||||
|
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
)}
|
||||||
|
{!showAll && hiddenCount > 0 && (
|
||||||
|
<UnstyledButton onClick={() => setShowAll(true)}>
|
||||||
|
<Text size="sm" c="edr-green.6">
|
||||||
|
Show all {def.options.length} options
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{multiple && (
|
||||||
|
<Button size="sm" onClick={() => apply()}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Group, NumberInput, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { NumberFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
export function NumberBody({ def, value, onChange, onClose }: FilterBodyProps<NumberFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.number);
|
||||||
|
const [from, setFrom] = useState<number | "">(value?.v[0] ? Number(value.v[0]) : "");
|
||||||
|
const [to, setTo] = useState<number | "">(op === "between" ? (Number(value?.v[1]) || "") : "");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (op === "between") {
|
||||||
|
onChange(from !== "" && to !== "" ? { op, v: [String(from), String(to)] } : undefined);
|
||||||
|
} else {
|
||||||
|
onChange(from !== "" ? { op, v: [String(from)] } : undefined);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{op === "between" ? (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<NumberInput placeholder="Min" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||||
|
<NumberInput placeholder="Max" value={to} onChange={(v) => setTo(v as number | "")} rightSection={def.unit} />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<NumberInput placeholder="Value" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Select, Stack } from "@mantine/core";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import type { RouteFilterDef } from "../types";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin + destination picked together, each a searchable `Select` over the
|
||||||
|
* page's yard list — typing filters by yard name, same as any Mantine
|
||||||
|
* Select. No `OperatorSelect`: a route pair has exactly one operator ("is"),
|
||||||
|
* which is why DEFAULT_OP.route is the only entry the generic bar needs.
|
||||||
|
*/
|
||||||
|
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
|
||||||
|
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null);
|
||||||
|
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// This popover already lives inside FilterPill's own Popover. A Select's
|
||||||
|
// dropdown portals separately by default, so a click on an option registers
|
||||||
|
// as "outside" the outer Popover and closes the whole filter before a pick
|
||||||
|
// lands — same nested-portal bug DateBody had. Un-portalling keeps it
|
||||||
|
// inside the outer popover's DOM subtree instead.
|
||||||
|
const comboboxProps = { withinPortal: false } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" w={240}>
|
||||||
|
<Select
|
||||||
|
label="Origin"
|
||||||
|
placeholder="Any"
|
||||||
|
data={def.options}
|
||||||
|
value={origin}
|
||||||
|
onChange={setOrigin}
|
||||||
|
comboboxProps={comboboxProps}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} />
|
||||||
|
<Select
|
||||||
|
label="Destination"
|
||||||
|
placeholder="Any"
|
||||||
|
data={def.options}
|
||||||
|
value={destination}
|
||||||
|
onChange={setDestination}
|
||||||
|
comboboxProps={comboboxProps}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={apply} disabled={!(origin && destination)}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { TextFilterDef, FilterValue, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
|
||||||
|
export interface FilterBodyProps<Def> {
|
||||||
|
def: Def;
|
||||||
|
value: FilterValue | undefined;
|
||||||
|
onChange: (v: FilterValue | undefined) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextBody({ def, value, onChange, onClose }: FilterBodyProps<TextFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.text);
|
||||||
|
const [text, setText] = useState(value?.v[0] ?? "");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onChange(text.trim() ? { op, v: [text.trim()] } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
<TextInput
|
||||||
|
placeholder={def.placeholder ?? `Filter by ${def.label.toLowerCase()}…`}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && apply()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { matchesDayRange, toDayString } from "@/hooks/useListControls";
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
|
||||||
|
export { matchesDayRange, toDayString };
|
||||||
|
|
||||||
|
const readField = (row: unknown, key: string): unknown =>
|
||||||
|
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||||
|
|
||||||
|
export interface ClientFilterOptions<T> {
|
||||||
|
/** Row fields matched against the free-text search box. */
|
||||||
|
searchKeys?: (keyof T)[];
|
||||||
|
/** Custom search extractor when the value isn't a top-level field. */
|
||||||
|
searchValue?: (row: T) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side bridge for pages whose endpoint doesn't (yet) accept
|
||||||
|
* filter/sort/pagination params — the Family-B pages this app inherited from
|
||||||
|
* `ListControls`/`useListControls`. Same idea, generalized: instead of one
|
||||||
|
* hardcoded search box + one date range, every `FilterDef` is matched
|
||||||
|
* against `row[def.key]` (override the def's `key` to line up with the row
|
||||||
|
* shape, or filter/map the rows before calling this).
|
||||||
|
*
|
||||||
|
* Flip a page to server mode later by deleting the `applyClientFilters` call
|
||||||
|
* and passing `controls.params` straight to the API — `useFilters`'s output
|
||||||
|
* shape doesn't change either way.
|
||||||
|
*
|
||||||
|
* ponytail: linear scan per keystroke, no debounce — matches
|
||||||
|
* `useListControls`'s existing behavior at this data size (~1k rows,
|
||||||
|
* `useListControls.ts:4-18`). Move to server-side filtering if a list
|
||||||
|
* outgrows that.
|
||||||
|
*/
|
||||||
|
export function applyClientFilters<T>(
|
||||||
|
rows: T[],
|
||||||
|
defs: FilterDef[],
|
||||||
|
values: Record<string, FilterValue>,
|
||||||
|
searchText: string,
|
||||||
|
options: ClientFilterOptions<T> = {},
|
||||||
|
): T[] {
|
||||||
|
const term = searchText.trim().toLowerCase();
|
||||||
|
const { searchKeys = [], searchValue } = options;
|
||||||
|
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (term) {
|
||||||
|
const haystack = searchValue
|
||||||
|
? searchValue(row)
|
||||||
|
: searchKeys.map((k) => String(readField(row, String(k)) ?? "")).join(" ");
|
||||||
|
if (!haystack.toLowerCase().includes(term)) return false;
|
||||||
|
}
|
||||||
|
for (const def of defs) {
|
||||||
|
const value = values[def.key];
|
||||||
|
if (!value) continue;
|
||||||
|
if (!matchesFilter(def, value, readField(row, def.key))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(def: FilterDef, value: FilterValue, raw: unknown): boolean {
|
||||||
|
switch (def.type) {
|
||||||
|
case "enum": {
|
||||||
|
const inSet = value.v.includes(String(raw ?? ""));
|
||||||
|
return value.op === "isNot" ? !inSet : inSet;
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
if (value.op === "between") {
|
||||||
|
return matchesDayRange(raw, value.v[0]?.slice(0, 10) ?? null, value.v[1]?.slice(0, 10) ?? null);
|
||||||
|
}
|
||||||
|
const day = toDayString(raw);
|
||||||
|
const target = value.v[0]?.slice(0, 10);
|
||||||
|
if (!day || !target) return false;
|
||||||
|
return value.op === "before" ? day <= target : day >= target;
|
||||||
|
}
|
||||||
|
case "number": {
|
||||||
|
const num = Number(raw);
|
||||||
|
if (Number.isNaN(num)) return false;
|
||||||
|
if (value.op === "between") {
|
||||||
|
const [min, max] = value.v.map(Number);
|
||||||
|
return num >= min && num <= max;
|
||||||
|
}
|
||||||
|
return value.op === "isNot" ? num !== Number(value.v[0]) : num === Number(value.v[0]);
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
return Boolean(raw) === (value.v[0] === "true");
|
||||||
|
case "text": {
|
||||||
|
const rawStr = String(raw ?? "").toLowerCase();
|
||||||
|
const target = (value.v[0] ?? "").toLowerCase();
|
||||||
|
return value.op === "isNot" ? !rawStr.includes(target) : rawStr.includes(target);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FilterValue } from "./types";
|
||||||
|
|
||||||
|
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
|
||||||
|
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
|
||||||
|
* every date filter shares one definition. */
|
||||||
|
export function startOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local end-of-day -> ISO, for inclusive "to" date filters. */
|
||||||
|
export function endOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(23, 59, 59, 999);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `YYYY-MM-DD` date-picker string into a LOCAL-midnight Date.
|
||||||
|
*
|
||||||
|
* `new Date("2026-01-01")` is a date-ONLY ISO string, which the spec parses
|
||||||
|
* as UTC midnight, not local midnight. For anyone east of UTC (Ethiopia is
|
||||||
|
* UTC+3) that instant already falls on the PREVIOUS local day, so
|
||||||
|
* `startOfDayIso`/`endOfDayIso` built from it silently shift the picked date
|
||||||
|
* back by one — the picker looks fine, the filtered results are wrong. This
|
||||||
|
* constructor form (`new Date(y, m, d)`) is local by definition; use it for
|
||||||
|
* every date-only string instead of `new Date(dateString)`.
|
||||||
|
*/
|
||||||
|
export function parseDateStr(dateStr: string): Date {
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
|
||||||
|
* operators. `DateBody` always emits a single-element `v` for before/after —
|
||||||
|
* a plain positional `{[fromKey]: v[0], [toKey]: v[1]}` mapping (the
|
||||||
|
* between-only default) would wrongly land a "before" pick in `fromKey`
|
||||||
|
* instead of `toKey`. This routes each operator to the right bound.
|
||||||
|
*/
|
||||||
|
export function dateRangeParams(
|
||||||
|
fromKey: string,
|
||||||
|
toKey: string,
|
||||||
|
): (v: FilterValue) => Record<string, string | undefined> {
|
||||||
|
return (value) => {
|
||||||
|
if (value.op === "before") return { [toKey]: value.v[0] };
|
||||||
|
if (value.op === "after") return { [fromKey]: value.v[0] };
|
||||||
|
return { [fromKey]: value.v[0], [toKey]: value.v[1] };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { parseFilters } from "./url";
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
|
||||||
|
/** Human-readable text for one filter's current value — same text a
|
||||||
|
* FilterPill shows, and what a saved view's auto-generated label is built
|
||||||
|
* from, so both read identically with zero duplicated logic. */
|
||||||
|
export function formatFilterValue(def: FilterDef, value: FilterValue): string {
|
||||||
|
if (def.format) return def.format(value, def);
|
||||||
|
if (def.type === "enum") {
|
||||||
|
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
|
||||||
|
return labels.join(", ");
|
||||||
|
}
|
||||||
|
if (def.type === "date" && value.v.length === 2) {
|
||||||
|
return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
if (def.type === "route" && value.v.length === 2) {
|
||||||
|
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
|
||||||
|
return `${label(value.v[0])} → ${label(value.v[1])}`;
|
||||||
|
}
|
||||||
|
return value.v.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated label for a saved view — "Status: Active, Draft · Direction:
|
||||||
|
* Import" — built straight from the filters it holds, instead of asking the
|
||||||
|
* user to type a name (which drifts out of sync with what the view actually
|
||||||
|
* filters the moment they edit it). Falls back to "All" when nothing decodes,
|
||||||
|
* though a view is only ever offered for saving with at least one active filter.
|
||||||
|
*
|
||||||
|
* Namespace-aware pages (`useFilters({ ns })`, for a second table on the same
|
||||||
|
* page) aren't decoded here — every current saved-view page is single-table.
|
||||||
|
* Thread `ns` through if/when that changes.
|
||||||
|
*/
|
||||||
|
export function describeQuery(defs: FilterDef[], query: string): string {
|
||||||
|
const values = parseFilters(defs, new URLSearchParams(query));
|
||||||
|
const parts = defs
|
||||||
|
.filter((d) => values[d.key])
|
||||||
|
.map((d) => `${d.label}: ${formatFilterValue(d, values[d.key])}`);
|
||||||
|
return parts.length ? parts.join(" · ") : "All";
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./url";
|
||||||
|
export * from "./dates";
|
||||||
|
export * from "./format";
|
||||||
|
export * from "./clientFilter";
|
||||||
|
export * from "./ruleEngineFooterProps";
|
||||||
|
export * from "./useFilters";
|
||||||
|
export * from "./useSavedViews";
|
||||||
|
export { FilterBar } from "./FilterBar";
|
||||||
|
export type { FilterBarProps } from "./FilterBar";
|
||||||
|
export { FilterPill } from "./FilterPill";
|
||||||
|
export { SortControl } from "./SortControl";
|
||||||
|
export { SaveViewButton } from "./SaveViewButton";
|
||||||
|
export { SavedViewCards } from "./SavedViewCards";
|
||||||
|
export { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||||
|
import type { UseFilters } from "./useFilters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts `useFilters`'s URL-backed page/pageSize to `RuleEngineListFooter`'s
|
||||||
|
* prop shape, for the client-bridge pages that render a plain `<Table>` +
|
||||||
|
* that footer instead of `<DataTable>` (which has `tableProps()` for this).
|
||||||
|
* Routes page-index vs page-size changes to the right setter — the same
|
||||||
|
* pageSize-gets-silently-dropped bug `tableProps()` had before it was fixed.
|
||||||
|
*/
|
||||||
|
export function toRuleEngineFooterProps(
|
||||||
|
controls: Pick<UseFilters, "page" | "pageSize" | "setPage" | "setPageSize">,
|
||||||
|
totalCount: number,
|
||||||
|
): {
|
||||||
|
pagination: PaginationState;
|
||||||
|
pageCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
} {
|
||||||
|
const { page, pageSize, setPage, setPageSize } = controls;
|
||||||
|
return {
|
||||||
|
pagination: { pageIndex: page - 1, pageSize },
|
||||||
|
pageCount: Math.max(1, Math.ceil(totalCount / pageSize)),
|
||||||
|
totalCount,
|
||||||
|
onPaginationChange: (updater) => {
|
||||||
|
const current = { pageIndex: page - 1, pageSize };
|
||||||
|
const next = typeof updater === "function" ? updater(current) : updater;
|
||||||
|
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||||
|
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
115
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
115
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export type FilterType = "text" | "enum" | "date" | "number" | "boolean" | "route";
|
||||||
|
|
||||||
|
export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after";
|
||||||
|
|
||||||
|
/** Operator implied by a filter's type when the def doesn't say otherwise. */
|
||||||
|
export const DEFAULT_OP: Record<FilterType, Operator> = {
|
||||||
|
text: "contains",
|
||||||
|
enum: "is",
|
||||||
|
date: "between",
|
||||||
|
number: "is",
|
||||||
|
boolean: "is",
|
||||||
|
route: "is",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||||
|
is: "is",
|
||||||
|
isNot: "is not",
|
||||||
|
contains: "contains",
|
||||||
|
between: "is between",
|
||||||
|
before: "is before",
|
||||||
|
after: "is after",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter's current value. `v` holds:
|
||||||
|
* - 1 entry for is / isNot / contains / before / after
|
||||||
|
* - 2 entries for between (range)
|
||||||
|
* - n entries for a multi-select enum (isAnyOf is expressed as op "is" + n values)
|
||||||
|
*/
|
||||||
|
export interface FilterValue {
|
||||||
|
op: Operator;
|
||||||
|
v: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacetBucket {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterDefBase {
|
||||||
|
/** URL key and, by default, the API param name. */
|
||||||
|
key: string;
|
||||||
|
/** Plain string — the caller applies i18n's t() before passing it in. */
|
||||||
|
label: string;
|
||||||
|
type: FilterType;
|
||||||
|
/** Defaults to `[DEFAULT_OP[type]]`. Widen only where the endpoint implements it. */
|
||||||
|
operators?: Operator[];
|
||||||
|
/** Pill text override. Default: "Label | value(s)". */
|
||||||
|
format?: (v: FilterValue, def: FilterDef) => string;
|
||||||
|
/** Map to API query params. Default `{ [key]: v.join(",") }`. */
|
||||||
|
toParams?: (v: FilterValue) => Record<string, string | undefined>;
|
||||||
|
/** Lives behind "More filters" until it has a value. Default false. */
|
||||||
|
secondary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFilterDef extends FilterDefBase {
|
||||||
|
type: "text";
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnumFilterDef extends FilterDefBase {
|
||||||
|
type: "enum";
|
||||||
|
options: FilterOption[];
|
||||||
|
/** Default true — checkbox list. false renders a single-select radio list. */
|
||||||
|
multiple?: boolean;
|
||||||
|
/** value -> count in the current (filtered) result set. Absent = no counts, hide nothing. */
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateFilterDef extends FilterDefBase {
|
||||||
|
type: "date";
|
||||||
|
calendar?: "gregorian" | "ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFilterDef extends FilterDefBase {
|
||||||
|
type: "number";
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanFilterDef extends FilterDefBase {
|
||||||
|
type: "boolean";
|
||||||
|
trueLabel?: string;
|
||||||
|
falseLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin + destination picked together as one pill — `v` is always the
|
||||||
|
* 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's
|
||||||
|
* Apply button stays disabled until both sides are chosen, same rule
|
||||||
|
* `DateBody` uses for a `between` range). One shared `options` list drives
|
||||||
|
* both selects.
|
||||||
|
*/
|
||||||
|
export interface RouteFilterDef extends FilterDefBase {
|
||||||
|
type: "route";
|
||||||
|
options: FilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterDef =
|
||||||
|
| TextFilterDef
|
||||||
|
| EnumFilterDef
|
||||||
|
| DateFilterDef
|
||||||
|
| NumberFilterDef
|
||||||
|
| BooleanFilterDef
|
||||||
|
| RouteFilterDef;
|
||||||
|
|
||||||
|
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
|
||||||
|
export interface SortOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import {
|
||||||
|
decodeFilterValue,
|
||||||
|
encodeFilterValue,
|
||||||
|
parseFilters,
|
||||||
|
parseSort,
|
||||||
|
toApiParams,
|
||||||
|
writeFilter,
|
||||||
|
} from "./url";
|
||||||
|
|
||||||
|
const STATUS: FilterDef = {
|
||||||
|
key: "statuses",
|
||||||
|
label: "Status",
|
||||||
|
type: "enum",
|
||||||
|
options: [
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION: FilterDef = {
|
||||||
|
key: "tradeDirection",
|
||||||
|
label: "Direction",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: [{ value: "IMPORT", label: "Import" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH: FilterDef = { key: "q", label: "Search", type: "text" };
|
||||||
|
|
||||||
|
const CREATED: FilterDef = {
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
type: "date",
|
||||||
|
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("encodeFilterValue / decodeFilterValue round-trip", () => {
|
||||||
|
const cases: Array<{ name: string; type: FilterDef["type"]; value: FilterValue }> = [
|
||||||
|
{ name: "text contains (default op omitted)", type: "text", value: { op: "contains", v: ["maersk"] } },
|
||||||
|
{ name: "enum is (default op omitted, multi value)", type: "enum", value: { op: "is", v: ["ACTIVE", "DRAFT"] } },
|
||||||
|
{ name: "enum isNot (non-default op prefixed)", type: "enum", value: { op: "isNot", v: ["GOV"] } },
|
||||||
|
{ name: "date between (default op omitted)", type: "date", value: { op: "between", v: ["2026-01-01", "2026-03-01"] } },
|
||||||
|
{ name: "date before (non-default op prefixed)", type: "date", value: { op: "before", v: ["2026-01-01"] } },
|
||||||
|
{ name: "number is", type: "number", value: { op: "is", v: ["42"] } },
|
||||||
|
{ name: "boolean is", type: "boolean", value: { op: "is", v: ["true"] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, type, value } of cases) {
|
||||||
|
it(`round-trips: ${name}`, () => {
|
||||||
|
const encoded = encodeFilterValue(type, value);
|
||||||
|
const decoded = decodeFilterValue(type, encoded);
|
||||||
|
expect(decoded).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("omits the operator prefix only when it is the type default", () => {
|
||||||
|
expect(encodeFilterValue("enum", { op: "is", v: ["ACTIVE"] })).toBe("ACTIVE");
|
||||||
|
expect(encodeFilterValue("enum", { op: "isNot", v: ["ACTIVE"] })).toBe("isNot:ACTIVE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never comma-splits a text value, so a literal comma survives", () => {
|
||||||
|
const encoded = encodeFilterValue("text", { op: "contains", v: ["Addis, Ethiopia"] });
|
||||||
|
expect(decodeFilterValue("text", encoded)).toEqual({ op: "contains", v: ["Addis, Ethiopia"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeFilterValue malformed-input tolerance", () => {
|
||||||
|
it("returns null for an empty string", () => {
|
||||||
|
expect(decodeFilterValue("text", "")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat an unknown prefix as an operator", () => {
|
||||||
|
// "foo" isn't a known Operator, so "foo:bar" is a literal text value, not op:value.
|
||||||
|
expect(decodeFilterValue("text", "foo:bar")).toEqual({ op: "contains", v: ["foo:bar"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades a one-sided 'between' to null (not applied) instead of guessing a half-open range", () => {
|
||||||
|
expect(decodeFilterValue("date", "between:2026-01-01")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on garbage input", () => {
|
||||||
|
expect(() => decodeFilterValue("enum", "isNot:")).not.toThrow();
|
||||||
|
expect(() => decodeFilterValue("date", "between:")).not.toThrow();
|
||||||
|
expect(() => decodeFilterValue("number", ":::")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseFilters / writeFilter", () => {
|
||||||
|
it("parses only the defs present, ignoring unrelated params", () => {
|
||||||
|
const params = new URLSearchParams("statuses=ACTIVE,DRAFT&unrelated=x&q=addis");
|
||||||
|
const values = parseFilters([STATUS, SEARCH], params);
|
||||||
|
expect(values).toEqual({
|
||||||
|
statuses: { op: "is", v: ["ACTIVE", "DRAFT"] },
|
||||||
|
q: { op: "contains", v: ["addis"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeFilter deletes the param when value is undefined", () => {
|
||||||
|
const params = new URLSearchParams("statuses=ACTIVE");
|
||||||
|
const next = writeFilter(params, STATUS, undefined);
|
||||||
|
expect(next.has("statuses")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeFilter round-trips through parseFilters", () => {
|
||||||
|
const value: FilterValue = { op: "is", v: ["IMPORT"] };
|
||||||
|
const next = writeFilter(new URLSearchParams(), DIRECTION, value);
|
||||||
|
expect(parseFilters([DIRECTION], next)).toEqual({ tradeDirection: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("namespaces keys when ns is given, so two tables on one page don't collide", () => {
|
||||||
|
const next = writeFilter(new URLSearchParams(), STATUS, { op: "is", v: ["ACTIVE"] }, "a");
|
||||||
|
expect(next.get("a.statuses")).toBe("ACTIVE");
|
||||||
|
expect(parseFilters([STATUS], new URLSearchParams(), "b")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("existing deep-link backward compatibility", () => {
|
||||||
|
it("parses the BookingRequestsPage-style ?statuses=A,B&tradeDirection=IMPORT link unchanged", () => {
|
||||||
|
const params = new URLSearchParams("statuses=SUBMITTED,APPROVED&tradeDirection=IMPORT");
|
||||||
|
expect(parseFilters([STATUS, DIRECTION], params)).toEqual({
|
||||||
|
statuses: { op: "is", v: ["SUBMITTED", "APPROVED"] },
|
||||||
|
tradeDirection: { op: "is", v: ["IMPORT"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSort", () => {
|
||||||
|
const options = [
|
||||||
|
{ value: "createdAt:DESC", label: "Newest first" },
|
||||||
|
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the fallback when sort is absent", () => {
|
||||||
|
expect(parseSort(new URLSearchParams(), options, "createdAt:DESC")).toBe("createdAt:DESC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the fallback for an unrecognized sort value", () => {
|
||||||
|
expect(parseSort(new URLSearchParams("sort=bogus:DESC"), options, "createdAt:DESC")).toBe(
|
||||||
|
"createdAt:DESC",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the URL value when it is a known option", () => {
|
||||||
|
expect(parseSort(new URLSearchParams("sort=createdAt:ASC"), options, "createdAt:DESC")).toBe(
|
||||||
|
"createdAt:ASC",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toApiParams", () => {
|
||||||
|
it("uses the default mapping (key: joined csv) when toParams is absent", () => {
|
||||||
|
const values = { statuses: { op: "is" as const, v: ["ACTIVE", "DRAFT"] } };
|
||||||
|
expect(toApiParams([STATUS], values)).toEqual({ statuses: "ACTIVE,DRAFT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a custom toParams to reproduce an existing API's exact param names", () => {
|
||||||
|
const values = { created: { op: "between" as const, v: ["2026-01-01", "2026-03-01"] } };
|
||||||
|
expect(toApiParams([CREATED], values)).toEqual({
|
||||||
|
createdFrom: "2026-01-01",
|
||||||
|
createdTo: "2026-03-01",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits defs with no value", () => {
|
||||||
|
expect(toApiParams([STATUS, SEARCH], {})).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { DEFAULT_OP, type FilterDef, type FilterValue, type Operator } from "./types";
|
||||||
|
|
||||||
|
const OPERATORS: readonly Operator[] = ["is", "isNot", "contains", "between", "before", "after"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode one filter value as `[op:]csv`, omitting the operator prefix when it
|
||||||
|
* matches the type's default — that keeps the common case short and, more
|
||||||
|
* importantly, keeps the existing `?statuses=A,B` deep links this app already
|
||||||
|
* generates (e.g. the header document-review alarm) parsing identically.
|
||||||
|
*
|
||||||
|
* `,` and `:` are structural inside an encoded value. A `text` filter is
|
||||||
|
* never comma-split (its `v` always has exactly one entry), which is what
|
||||||
|
* lets a free-text search contain a literal comma safely.
|
||||||
|
* ponytail: if a value ever legitimately needs a literal "op:" prefix or a
|
||||||
|
* comma inside a multi-value filter, switch that filter to a JSON-in-one-param
|
||||||
|
* encoding rather than trying to escape these two characters.
|
||||||
|
*/
|
||||||
|
export function encodeFilterValue(type: FilterDef["type"], value: FilterValue): string {
|
||||||
|
const csv = value.v.map(encodeURIComponent).join(",");
|
||||||
|
return value.op === DEFAULT_OP[type] ? csv : `${value.op}:${csv}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of `encodeFilterValue`. Returns null for anything malformed — a bad
|
||||||
|
* URL is user input and must degrade to "filter not applied", never throw. */
|
||||||
|
export function decodeFilterValue(type: FilterDef["type"], raw: string): FilterValue | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const firstColon = raw.indexOf(":");
|
||||||
|
let op: Operator = DEFAULT_OP[type];
|
||||||
|
let rest = raw;
|
||||||
|
if (firstColon > 0) {
|
||||||
|
const prefix = raw.slice(0, firstColon);
|
||||||
|
if ((OPERATORS as string[]).includes(prefix)) {
|
||||||
|
op = prefix as Operator;
|
||||||
|
rest = raw.slice(firstColon + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Text filters are single-value and never comma-split, so a literal comma
|
||||||
|
// in a search term round-trips unchanged.
|
||||||
|
const v =
|
||||||
|
type === "text"
|
||||||
|
? [decodeURIComponent(rest)]
|
||||||
|
: rest.split(",").filter(Boolean).map(decodeURIComponent);
|
||||||
|
if (v.length === 0) return null;
|
||||||
|
// A malformed range (wrong arity) has no safe single-sided interpretation —
|
||||||
|
// "between:2026-01-01" doesn't say whether that's the from or the to — so
|
||||||
|
// it degrades to "filter not applied" rather than guessing a half-open range.
|
||||||
|
if (op === "between" && v.length !== 2) return null;
|
||||||
|
return { op, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every FilterDef's current value, parsed from the URL. Unknown/malformed entries are dropped. */
|
||||||
|
export function parseFilters(
|
||||||
|
defs: FilterDef[],
|
||||||
|
params: URLSearchParams,
|
||||||
|
ns?: string,
|
||||||
|
): Record<string, FilterValue> {
|
||||||
|
const out: Record<string, FilterValue> = {};
|
||||||
|
for (const def of defs) {
|
||||||
|
const raw = params.get(nsKey(def.key, ns));
|
||||||
|
if (!raw) continue;
|
||||||
|
const value = decodeFilterValue(def.type, raw);
|
||||||
|
if (value) out[def.key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write (or delete) one filter's value into a URLSearchParams, returning a new instance. */
|
||||||
|
export function writeFilter(
|
||||||
|
params: URLSearchParams,
|
||||||
|
def: FilterDef,
|
||||||
|
value: FilterValue | undefined,
|
||||||
|
ns?: string,
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
const key = nsKey(def.key, ns);
|
||||||
|
if (!value || value.v.length === 0) next.delete(key);
|
||||||
|
else next.set(key, encodeFilterValue(def.type, value));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nsKey(key: string, ns?: string): string {
|
||||||
|
return ns ? `${ns}.${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?sort=field:DIR` -> `"field:DIR"`, defaulting when absent/unrecognized. */
|
||||||
|
export function parseSort(params: URLSearchParams, options: { value: string }[], fallback: string): string {
|
||||||
|
const raw = params.get("sort");
|
||||||
|
if (raw && options.some((o) => o.value === raw)) return raw;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten every def's parsed value into the flat param object a page's
|
||||||
|
* react-query filter object / axios params already expect. `toParams`
|
||||||
|
* defaults to `{ [key]: v.join(",") }`, which reproduces exactly what
|
||||||
|
* `?statuses=A,B` meant before this bar existed.
|
||||||
|
*/
|
||||||
|
export function toApiParams(
|
||||||
|
defs: FilterDef[],
|
||||||
|
values: Record<string, FilterValue>,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
for (const def of defs) {
|
||||||
|
const value = values[def.key];
|
||||||
|
if (!value) continue;
|
||||||
|
const mapped = def.toParams ? def.toParams(value) : { [def.key]: value.v.join(",") };
|
||||||
|
Object.assign(out, mapped);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete emptystring/undefined/null entries — never send them, never write them to the URL. */
|
||||||
|
export function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import type { DataTablePagination, DataTableProps } from "@edr/ui-common";
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import { cleanParams, parseFilters, toApiParams, writeFilter } from "./url";
|
||||||
|
|
||||||
|
export interface UseFiltersOptions {
|
||||||
|
/** value = "field:DIR", matching the codebase's existing sort convention. */
|
||||||
|
defaultSort?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
/** "page" (freight: {page,pageSize}) or "skip" (record-management: {skip,take}). */
|
||||||
|
paginationStyle?: "page" | "skip";
|
||||||
|
/** Namespaces URL keys ("<ns>.<key>") for pages with two independent tables. */
|
||||||
|
ns?: string;
|
||||||
|
/** Search box debounce, ms. */
|
||||||
|
searchDebounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFilters {
|
||||||
|
values: Record<string, FilterValue>;
|
||||||
|
/** Flat params ready for the react-query key + axios `params` — IS the query key input. */
|
||||||
|
params: Record<string, string | number>;
|
||||||
|
sort: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
searchText: string;
|
||||||
|
setSearchText: (s: string) => void;
|
||||||
|
setFilter: (key: string, value: FilterValue | undefined) => void;
|
||||||
|
removeFilter: (key: string) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
setSort: (s: string) => void;
|
||||||
|
setPage: (p: number) => void;
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
activeCount: number;
|
||||||
|
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
|
||||||
|
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
|
||||||
|
/** Replace the whole URL (saved-view restore). Pushes, so Back undoes it. */
|
||||||
|
applyQueryString: (query: string) => void;
|
||||||
|
/** Current filter state as a raw query string, for saving as a view (page stripped). */
|
||||||
|
currentQueryString: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}): UseFilters {
|
||||||
|
const {
|
||||||
|
defaultSort = "",
|
||||||
|
pageSize: defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||||
|
paginationStyle = "page",
|
||||||
|
ns,
|
||||||
|
searchDebounceMs = 300,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [sp, setSp] = useSearchParams();
|
||||||
|
const searchKey = ns ? `${ns}.q` : "q";
|
||||||
|
const pageKey = ns ? `${ns}.page` : "page";
|
||||||
|
const sizeKey = ns ? `${ns}.size` : "size";
|
||||||
|
|
||||||
|
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
|
||||||
|
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
|
||||||
|
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
|
||||||
|
const pageSize = Math.max(1, Number(sp.get(sizeKey)) || defaultPageSize);
|
||||||
|
|
||||||
|
// Free text: local draft debounced into the URL with `replace`, so typing
|
||||||
|
// leaves exactly one history entry instead of one per keystroke.
|
||||||
|
const [searchText, setSearchTextState] = useState(() => sp.get(searchKey) ?? "");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(searchText, searchDebounceMs);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlValue = sp.get(searchKey) ?? "";
|
||||||
|
if (urlValue === debouncedSearch) return;
|
||||||
|
setSp(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (debouncedSearch) next.set(searchKey, debouncedSearch);
|
||||||
|
else next.delete(searchKey);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// External navigation (back/forward, saved-view restore, deep link) —
|
||||||
|
// sync the local draft from the URL. Comparing against the debounced
|
||||||
|
// value (not `searchText`) is what stops this from clobbering an
|
||||||
|
// in-flight keystroke: mid-type, urlValue !== debouncedSearch is expected.
|
||||||
|
const urlValue = sp.get(searchKey) ?? "";
|
||||||
|
if (urlValue !== debouncedSearch) setSearchTextState(urlValue);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sp]);
|
||||||
|
|
||||||
|
const setFilter = useCallback(
|
||||||
|
(key: string, value: FilterValue | undefined) => {
|
||||||
|
const def = defs.find((d) => d.key === key);
|
||||||
|
if (!def) return;
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = writeFilter(prev, def, value, ns);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defs, ns, pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFilter = useCallback((key: string) => setFilter(key, undefined), [setFilter]);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
for (const def of defs) next.delete(ns ? `${ns}.${def.key}` : def.key);
|
||||||
|
next.delete(searchKey);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSearchTextState("");
|
||||||
|
}, [defs, ns, pageKey, searchKey, setSp]);
|
||||||
|
|
||||||
|
const setSort = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
const key = ns ? `${ns}.sort` : "sort";
|
||||||
|
if (value === defaultSort) next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultSort, ns, pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPage = useCallback(
|
||||||
|
(p: number) => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (p <= 1) next.delete(pageKey);
|
||||||
|
else next.set(pageKey, String(p));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPageSize = useCallback(
|
||||||
|
(size: number) => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (size === defaultPageSize) next.delete(sizeKey);
|
||||||
|
else next.set(sizeKey, String(size));
|
||||||
|
next.delete(pageKey); // a different page size invalidates the current page index
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultPageSize, sizeKey, pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const filterParams = toApiParams(defs, values);
|
||||||
|
const base: Record<string, string | number | undefined> =
|
||||||
|
paginationStyle === "skip"
|
||||||
|
? { skip: (page - 1) * pageSize, take: pageSize }
|
||||||
|
: { page, pageSize };
|
||||||
|
if (debouncedSearch) base.search = debouncedSearch;
|
||||||
|
if (sort) {
|
||||||
|
if (paginationStyle === "skip") base.orderBy = sort;
|
||||||
|
else {
|
||||||
|
const [sortBy, sortOrder] = sort.split(":");
|
||||||
|
base.sortBy = sortBy;
|
||||||
|
base.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanParams({ ...filterParams, ...base }) as Record<string, string | number>;
|
||||||
|
}, [defs, values, paginationStyle, page, pageSize, debouncedSearch, sort]);
|
||||||
|
|
||||||
|
const activeCount = Object.keys(values).length + (debouncedSearch ? 1 : 0);
|
||||||
|
|
||||||
|
const tableProps = useCallback(
|
||||||
|
(total: number): Pick<DataTableProps<any, any>, "pagination" | "tableOptions"> => {
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const pagination: DataTablePagination = { pageIndex: page - 1, pageSize, pageCount, totalCount: total };
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
tableOptions: {
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
state: { pagination: { pageIndex: page - 1, pageSize } },
|
||||||
|
onPaginationChange: (updater) => {
|
||||||
|
const current = { pageIndex: page - 1, pageSize };
|
||||||
|
const next = typeof updater === "function" ? updater(current) : updater;
|
||||||
|
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||||
|
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, pageSize, setPage, setPageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyQueryString = useCallback(
|
||||||
|
(query: string) => setSp(new URLSearchParams(query)),
|
||||||
|
[setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentQueryString = useCallback(() => {
|
||||||
|
const next = new URLSearchParams(sp);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next.toString();
|
||||||
|
}, [sp, pageKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
params,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
searchText,
|
||||||
|
setSearchText: setSearchTextState,
|
||||||
|
setFilter,
|
||||||
|
removeFilter,
|
||||||
|
clearFilters,
|
||||||
|
setSort,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
activeCount,
|
||||||
|
tableProps,
|
||||||
|
applyQueryString,
|
||||||
|
currentQueryString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toApiParams } from "./url";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
|
|
||||||
|
export interface SavedView {
|
||||||
|
id: string;
|
||||||
|
/** Raw query string ("statuses=ACTIVE&sort=createdAt:DESC") — the label is
|
||||||
|
* derived from this at render time (see format.ts's describeQuery), so
|
||||||
|
* there's nothing else to keep in sync. */
|
||||||
|
query: string;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage namespace is per PAGE (viewId), not per user — this is a
|
||||||
|
* backoffice, one staff login per browser profile.
|
||||||
|
* ponytail: add ":<userId>" if shared-terminal login appears.
|
||||||
|
*/
|
||||||
|
export function useSavedViews(viewId: string) {
|
||||||
|
const [views, setViews] = useLocalStorage<SavedView[]>({
|
||||||
|
key: `edr:saved-views:${viewId}`,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = (query: string): SavedView => {
|
||||||
|
const view: SavedView = { id: crypto.randomUUID(), query, savedAt: Date.now() };
|
||||||
|
setViews((prev) => [...prev, view]);
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id));
|
||||||
|
|
||||||
|
return { views, save, remove };
|
||||||
|
}
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { EimsInvoiceStatus } from "@/types/eims";
|
import { eimsService } from "@/services/eims.service";
|
||||||
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
|||||||
REGISTERED: "edr-green",
|
REGISTERED: "edr-green",
|
||||||
FAILED: "red",
|
FAILED: "red",
|
||||||
UNKNOWN: "orange",
|
UNKNOWN: "orange",
|
||||||
|
CANCELLED: "gray",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
|||||||
REGISTERED: "Filed",
|
REGISTERED: "Filed",
|
||||||
FAILED: "Rejected",
|
FAILED: "Rejected",
|
||||||
UNKNOWN: "Unacknowledged",
|
UNKNOWN: "Unacknowledged",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||||
|
const CANCEL_REASON_CODES = [
|
||||||
|
{ value: "1", label: "1 — Duplicate" },
|
||||||
|
{ value: "2", label: "2 — Buyer request" },
|
||||||
|
{ value: "3", label: "3 — Data entry error" },
|
||||||
|
{ value: "6", label: "6 — Calculation error" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CancelModal({
|
||||||
|
invoiceId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
invoiceId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||||
|
const [remark, setRemark] = useState("");
|
||||||
|
|
||||||
|
const cancel = useMutation(
|
||||||
|
api.invoices.eimsCancel.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
toast({ title: "Cancelled with MoR" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled
|
||||||
|
invoice refuses a second attempt.
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label="Reason code"
|
||||||
|
withAsterisk
|
||||||
|
data={CANCEL_REASON_CODES}
|
||||||
|
value={reasonCode}
|
||||||
|
onChange={setReasonCode}
|
||||||
|
placeholder="Select a reason"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Remark"
|
||||||
|
placeholder="Optional note"
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
loading={cancel.isPending}
|
||||||
|
disabled={!reasonCode}
|
||||||
|
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Cancel with MoR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SalesReceiptModal({
|
||||||
|
invoiceId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
invoiceId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
|
||||||
|
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const register = useMutation(
|
||||||
|
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
|
||||||
|
onSuccess: (receipt) => {
|
||||||
|
onClose();
|
||||||
|
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Mode of payment"
|
||||||
|
withAsterisk
|
||||||
|
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
|
||||||
|
value={modeOfPayment}
|
||||||
|
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
|
||||||
|
placeholder="Select"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Collected amount"
|
||||||
|
placeholder="Defaults to the invoice's paid amount"
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
value={collectedAmount}
|
||||||
|
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Reason"
|
||||||
|
placeholder='Defaults to "Payment received"'
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Send size={16} />}
|
||||||
|
loading={register.isPending}
|
||||||
|
disabled={!modeOfPayment}
|
||||||
|
onClick={() =>
|
||||||
|
register.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
modeOfPayment: modeOfPayment!,
|
||||||
|
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
File with MoR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WithholdingReceiptModal({
|
||||||
|
invoiceId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
invoiceId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [type, setType] = useState("TWHT");
|
||||||
|
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
|
||||||
|
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const register = useMutation(
|
||||||
|
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
|
||||||
|
onSuccess: (receipt) => {
|
||||||
|
onClose();
|
||||||
|
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const valid = preTaxAmount !== "" && withholdingAmount !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
|
||||||
|
<NumberInput
|
||||||
|
label="Pre-tax amount"
|
||||||
|
withAsterisk
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
value={preTaxAmount}
|
||||||
|
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Withholding amount"
|
||||||
|
withAsterisk
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
value={withholdingAmount}
|
||||||
|
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Reason"
|
||||||
|
placeholder='Defaults to "Withholding"'
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Send size={16} />}
|
||||||
|
loading={register.isPending}
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() =>
|
||||||
|
register.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
type,
|
||||||
|
preTaxAmount: preTaxAmount as number,
|
||||||
|
withholdingAmount: withholdingAmount as number,
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
File with MoR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemoModal({
|
||||||
|
invoiceId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
invoiceId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [type, setType] = useState<"CRE" | "DEB">("CRE");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const issue = useMutation(
|
||||||
|
api.invoices.issueMemo.mutationOptions({
|
||||||
|
onSuccess: (memo) => {
|
||||||
|
onClose();
|
||||||
|
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
|
||||||
|
MoR is a separate step — it does not happen automatically here.
|
||||||
|
</Text>
|
||||||
|
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
|
||||||
|
<Stack gap="xs" mt="xs">
|
||||||
|
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
|
||||||
|
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
withAsterisk
|
||||||
|
placeholder="Why this memo is being issued"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={issue.isPending}
|
||||||
|
disabled={!reason.trim()}
|
||||||
|
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
|
||||||
|
>
|
||||||
|
Issue memo
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
|
||||||
|
const [salesOpen, setSalesOpen] = useState(false);
|
||||||
|
const [withholdingOpen, setWithholdingOpen] = useState(false);
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const download = async (receiptId: string, receiptNumber: string) => {
|
||||||
|
setDownloadingId(receiptId);
|
||||||
|
try {
|
||||||
|
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
|
||||||
|
openPdfBlob(data, `${receiptNumber}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Could not download receipt",
|
||||||
|
description: error instanceof Error ? error.message : undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
Receipts
|
||||||
|
</Text>
|
||||||
|
{canFile && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
|
||||||
|
File sales receipt
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
|
||||||
|
File withholding receipt
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{receipts && receipts.length > 0 ? (
|
||||||
|
<Table striped withTableBorder={false} verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Kind</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>RRN</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{receipts.map((r) => (
|
||||||
|
<Table.Tr key={r.id}>
|
||||||
|
<Table.Td>{r.kind}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
|
||||||
|
{STATUS_LABEL[r.status] ?? r.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{r.status === "REGISTERED" && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
loading={downloadingId === r.id}
|
||||||
|
onClick={() => void download(r.id, r.receiptNumber)}
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No receipts filed yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
|
||||||
|
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||||
*
|
*
|
||||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||||
|
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
|
||||||
|
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
|
||||||
|
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
|
||||||
|
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [memoOpen, setMemoOpen] = useState(false);
|
||||||
|
|
||||||
const { data: eims, isLoading } = useQuery(
|
const { data: eims, isLoading } = useQuery(
|
||||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canFile && (
|
{status === "CANCELLED" && (
|
||||||
<Group gap="sm">
|
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
{eims.eimsCancellationRemark}
|
||||||
<Button
|
</Alert>
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
loading={register.isPending}
|
|
||||||
disabled={busy}
|
|
||||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
|
||||||
onClick={() => register.mutate({ id: invoiceId })}
|
|
||||||
>
|
|
||||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{eims.eimsIrn && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
loading={verify.isPending}
|
|
||||||
disabled={busy}
|
|
||||||
leftSection={<ShieldCheck size={14} />}
|
|
||||||
onClick={() => verify.mutate({ id: invoiceId })}
|
|
||||||
>
|
|
||||||
Verify with MoR
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
{canFile && (
|
||||||
|
<>
|
||||||
|
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||||
|
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
loading={register.isPending}
|
||||||
|
disabled={busy}
|
||||||
|
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||||
|
onClick={() => register.mutate({ id: invoiceId })}
|
||||||
|
>
|
||||||
|
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eims.eimsIrn && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
loading={verify.isPending}
|
||||||
|
disabled={busy}
|
||||||
|
leftSection={<ShieldCheck size={14} />}
|
||||||
|
onClick={() => verify.mutate({ id: invoiceId })}
|
||||||
|
>
|
||||||
|
Verify with MoR
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Ban size={14} />}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
|
>
|
||||||
|
Cancel with MoR
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canIssueMemo && status === "REGISTERED" && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
onClick={() => setMemoOpen(true)}
|
||||||
|
>
|
||||||
|
Issue credit/debit memo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||||
|
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const QUERY_KEYS = {
|
|||||||
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
|
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
|
||||||
["invoices", "summary", filter ?? {}] as const,
|
["invoices", "summary", filter ?? {}] as const,
|
||||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||||
|
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const URL_CONSTANTS = {
|
|||||||
INVOICES_SUMMARY: "/billing/invoices/summary",
|
INVOICES_SUMMARY: "/billing/invoices/summary",
|
||||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
|
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
|
||||||
OFFLINE_USD: "/billing/offline-usd",
|
OFFLINE_USD: "/billing/offline-usd",
|
||||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||||
},
|
},
|
||||||
@@ -147,6 +148,12 @@ export const URL_CONSTANTS = {
|
|||||||
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
|
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
|
||||||
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
|
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
|
||||||
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
|
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
|
||||||
|
CANCEL: (id: string) => `/invoices/${id}/eims/cancel`,
|
||||||
|
RECEIPT_SALES: (id: string) => `/invoices/${id}/eims/receipt/sales`,
|
||||||
|
RECEIPT_WITHHOLDING: (id: string) => `/invoices/${id}/eims/receipt/withholding`,
|
||||||
|
RECEIPTS: (id: string) => `/invoices/${id}/eims/receipts`,
|
||||||
|
RECEIPT_DOCUMENT: (id: string, receiptId: string) =>
|
||||||
|
`/invoices/${id}/eims/receipts/${receiptId}/document`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
|
|||||||
@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:invoices:view",
|
view: "edr_freight_app:invoices:view",
|
||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
|
||||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
// at all (the cron sweep runs as the system); these are the manual, exceptional-operations
|
||||||
|
// actions, granted to the `chief` position (maker-checker, same as shipping-line credit
|
||||||
|
// mark-paid/cancel approval) rather than every Finance user.
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
|
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||||
|
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||||
|
// Issuing a credit/debit memo is filing-equivalent — same restricted grant as the eims_* keys.
|
||||||
|
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
view: "edr_freight_app:first_mile:view",
|
view: "edr_freight_app:first_mile:view",
|
||||||
|
|||||||
@@ -1,44 +1,32 @@
|
|||||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
|
||||||
Collapse,
|
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
MultiSelect,
|
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
|
||||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FilterX,
|
|
||||||
LayoutList,
|
LayoutList,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
import { FilterToggle } from "@/components/common/FilterToggle";
|
|
||||||
import { formatDate, humanize } from "@/lib/format";
|
import { formatDate, humanize } from "@/lib/format";
|
||||||
|
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||||
@@ -63,7 +51,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
@@ -104,61 +91,11 @@ const OWNERSHIP_OPTIONS = [
|
|||||||
{ value: "false", label: "Private" },
|
{ value: "false", label: "Private" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
|
||||||
function startOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(0, 0, 0, 0);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
|
||||||
function endOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(23, 59, 59, 999);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookingRequestsPage() {
|
export default function BookingRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
|
||||||
// the header's document-review alarm opens exactly the undecided requests it
|
|
||||||
// is counting down for. Read once as the initial state so staff can then
|
|
||||||
// change the filters like any other visit.
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
|
||||||
// Booking kind is a filter now — one list holds both kinds (null = "all").
|
|
||||||
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
|
|
||||||
// Filter controls (empty/null = "all").
|
|
||||||
const paramStatuses = searchParams.get("statuses") ?? "";
|
|
||||||
const paramDirection = searchParams.get("tradeDirection");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
|
||||||
paramStatuses.split(",").filter(Boolean),
|
|
||||||
);
|
|
||||||
const { filterOptions } = useMyTradeAccess();
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
|
||||||
paramDirection,
|
|
||||||
);
|
|
||||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
|
||||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
|
|
||||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
|
||||||
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
|
|
||||||
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
|
|
||||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
|
||||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
|
||||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
|
||||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
|
||||||
// Direction is the only deep-linkable advanced filter — open the panel so a
|
|
||||||
// deep link never hides its own filter.
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(() =>
|
|
||||||
Boolean(paramDirection),
|
|
||||||
);
|
|
||||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||||
// Paid bookings with no train attached (staff removed them or a sweep
|
|
||||||
// detached them) — the queue the per-row Allocate action works through.
|
|
||||||
const [paidUnallocated, setPaidUnallocated] = useState(false);
|
|
||||||
const [allocatingId, setAllocatingId] = useState<string | null>(null);
|
const [allocatingId, setAllocatingId] = useState<string | null>(null);
|
||||||
const [otherDayModal, setOtherDayModal] = useState<{
|
const [otherDayModal, setOtherDayModal] = useState<{
|
||||||
booking: BookingListRow;
|
booking: BookingListRow;
|
||||||
@@ -173,77 +110,6 @@ export default function BookingRequestsPage() {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Follow the URL when a deep link arrives while the page is already open
|
|
||||||
// (clicking the header alarm from this very list). Same-value writes are
|
|
||||||
// dropped so a manual filter change is never undone.
|
|
||||||
useEffect(() => {
|
|
||||||
const next = paramStatuses.split(",").filter(Boolean);
|
|
||||||
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
|
|
||||||
setDirectionFilter(paramDirection);
|
|
||||||
if (paramDirection) setShowAdvanced(true);
|
|
||||||
}, [paramStatuses, paramDirection]);
|
|
||||||
|
|
||||||
const filter: BookingListFilter = useMemo(() => {
|
|
||||||
return {
|
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
sortBy: "createdAt",
|
|
||||||
sortOrder: "DESC",
|
|
||||||
// React Query cache key per kind selection ("ALL" when unfiltered).
|
|
||||||
tab: kindFilter ?? "ALL",
|
|
||||||
...(kindFilter ? { bookingType: kindFilter } : {}),
|
|
||||||
// Server-side free-text search (booking ref, customer, contract ref).
|
|
||||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
|
||||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
|
||||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
|
||||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
|
||||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
|
||||||
// Wins over the payment-status select — the queue is by definition PAID.
|
|
||||||
...(paidUnallocated
|
|
||||||
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
|
|
||||||
: {}),
|
|
||||||
...(ownershipFilter
|
|
||||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
|
||||||
: {}),
|
|
||||||
...(originYardFilter ? { originYardId: originYardFilter } : {}),
|
|
||||||
...(destinationYardFilter
|
|
||||||
? { destinationYardId: destinationYardFilter }
|
|
||||||
: {}),
|
|
||||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
|
||||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
|
||||||
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
|
|
||||||
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
pagination.pageIndex,
|
|
||||||
pagination.pageSize,
|
|
||||||
kindFilter,
|
|
||||||
debouncedQuery,
|
|
||||||
statusFilter,
|
|
||||||
directionFilter,
|
|
||||||
freightTypeFilter,
|
|
||||||
paymentStatusFilter,
|
|
||||||
paidUnallocated,
|
|
||||||
ownershipFilter,
|
|
||||||
originYardFilter,
|
|
||||||
destinationYardFilter,
|
|
||||||
createdFrom,
|
|
||||||
createdTo,
|
|
||||||
scheduledFrom,
|
|
||||||
scheduledTo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
|
||||||
const primaryAllocateId = allocateIds[0];
|
|
||||||
const { data: allocateBooking } = useBookingDetail(
|
|
||||||
allocateOpen ? primaryAllocateId : undefined,
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
data: summary,
|
|
||||||
isLoading: summaryLoading,
|
|
||||||
refetch: refetchSummary,
|
|
||||||
} = useBookingListSummary(filter);
|
|
||||||
|
|
||||||
// Yard options for the origin/destination filters (shared routes reference list).
|
// Yard options for the origin/destination filters (shared routes reference list).
|
||||||
const { data: yardRefs } = useQuery(
|
const { data: yardRefs } = useQuery(
|
||||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||||
@@ -257,43 +123,72 @@ export default function BookingRequestsPage() {
|
|||||||
[yardRefs],
|
[yardRefs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetPage = useCallback(() => {
|
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
// the header's document-review alarm opens exactly the undecided requests
|
||||||
}, [setPagination, pagination.pageSize]);
|
// it is counting down for. No sync effect needed any more: controls.values
|
||||||
|
// reads live off the URL every render, so a link opened while this page is
|
||||||
|
// already mounted just works, and every filter — direction included —
|
||||||
|
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
|
||||||
|
// split), so a deep link can never land behind "More filters" unseen.
|
||||||
|
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
|
||||||
|
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||||
|
{
|
||||||
|
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
|
||||||
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||||
|
},
|
||||||
|
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
|
||||||
|
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
|
||||||
|
{
|
||||||
|
// Wins over the `paymentStatus` filter above — the queue is by
|
||||||
|
// definition PAID — because it's later in this array: toApiParams
|
||||||
|
// merges defs in order, so a later toParams overwrites an earlier one.
|
||||||
|
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
|
||||||
|
trueLabel: "Paid, not allocated",
|
||||||
|
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
|
||||||
|
},
|
||||||
|
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
|
||||||
|
{
|
||||||
|
key: "route", label: "Route", type: "route", options: yardOptions,
|
||||||
|
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created", label: "Created", type: "date", secondary: true,
|
||||||
|
operators: ["between", "before", "after"],
|
||||||
|
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
|
||||||
|
operators: ["between", "before", "after"],
|
||||||
|
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[filterOptions, yardOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const activeFilterCount =
|
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
|
||||||
(kindFilter ? 1 : 0) +
|
|
||||||
(statusFilter.length ? 1 : 0) +
|
|
||||||
(directionFilter ? 1 : 0) +
|
|
||||||
(freightTypeFilter ? 1 : 0) +
|
|
||||||
(paymentStatusFilter ? 1 : 0) +
|
|
||||||
(paidUnallocated ? 1 : 0) +
|
|
||||||
(ownershipFilter ? 1 : 0) +
|
|
||||||
(originYardFilter ? 1 : 0) +
|
|
||||||
(destinationYardFilter ? 1 : 0) +
|
|
||||||
(createdFrom || createdTo ? 1 : 0) +
|
|
||||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
|
||||||
|
|
||||||
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
const filter: BookingListFilter = useMemo(
|
||||||
const advancedFilterCount =
|
() => ({
|
||||||
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
|
...(controls.params as unknown as BookingListFilter),
|
||||||
|
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
||||||
|
// kept as a param the API ignores, matching the pre-migration cache key.
|
||||||
|
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||||
|
}),
|
||||||
|
[controls.params, controls.values.bookingType],
|
||||||
|
);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||||
setKindFilter(null);
|
const primaryAllocateId = allocateIds[0];
|
||||||
setStatusFilter([]);
|
const { data: allocateBooking } = useBookingDetail(
|
||||||
setDirectionFilter(null);
|
allocateOpen ? primaryAllocateId : undefined,
|
||||||
setFreightTypeFilter(null);
|
);
|
||||||
setPaymentStatusFilter(null);
|
const {
|
||||||
setPaidUnallocated(false);
|
data: summary,
|
||||||
setOwnershipFilter(null);
|
isLoading: summaryLoading,
|
||||||
setOriginYardFilter(null);
|
refetch: refetchSummary,
|
||||||
setDestinationYardFilter(null);
|
} = useBookingListSummary(filter);
|
||||||
setCreatedFrom(null);
|
|
||||||
setCreatedTo(null);
|
|
||||||
setScheduledFrom(null);
|
|
||||||
setScheduledTo(null);
|
|
||||||
resetPage();
|
|
||||||
}, [resetPage]);
|
|
||||||
|
|
||||||
// Search is applied server-side (via the `search` filter param) — no
|
// Search is applied server-side (via the `search` filter param) — no
|
||||||
// client-side filtering here.
|
// client-side filtering here.
|
||||||
@@ -303,8 +198,7 @@ export default function BookingRequestsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
const hasSearch = controls.searchText.trim().length > 0;
|
||||||
const hasSearch = query.trim().length > 0;
|
|
||||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||||
|
|
||||||
const metrics = summary?.metrics;
|
const metrics = summary?.metrics;
|
||||||
@@ -585,195 +479,13 @@ export default function BookingRequestsPage() {
|
|||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||||
<Stack gap="sm">
|
<FilterBar
|
||||||
<Group gap="sm" wrap="wrap">
|
defs={bookingFilterDefs}
|
||||||
<TextInput
|
controls={controls}
|
||||||
placeholder="Search booking, contract or customer…"
|
searchPlaceholder="Search booking, contract or customer…"
|
||||||
leftSection={<Search size={18} />}
|
viewId="booking-requests"
|
||||||
value={query}
|
/>
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
rightSection={
|
|
||||||
query && (
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
variant="transparent"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ flex: 1, minWidth: "200px" }}
|
|
||||||
radius="lg"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All booking types"
|
|
||||||
data={BOOKING_KIND_OPTIONS}
|
|
||||||
value={kindFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setKindFilter((v as BookingKind | null) ?? null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 190 }}
|
|
||||||
/>
|
|
||||||
<MultiSelect
|
|
||||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
|
||||||
data={STATUS_OPTIONS}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setStatusFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
/>
|
|
||||||
<FilterToggle
|
|
||||||
count={advancedFilterCount}
|
|
||||||
expanded={showAdvanced}
|
|
||||||
onClick={() => setShowAdvanced((v) => !v)}
|
|
||||||
/>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<FilterX size={16} />}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear filters ({activeFilterCount})
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Collapse expanded={showAdvanced}>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<Select
|
|
||||||
placeholder="All origins"
|
|
||||||
data={yardOptions}
|
|
||||||
value={originYardFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setOriginYardFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 180 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All destinations"
|
|
||||||
data={yardOptions}
|
|
||||||
value={destinationYardFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setDestinationYardFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 180 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All directions"
|
|
||||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
|
||||||
value={directionFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setDirectionFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All freight types"
|
|
||||||
data={FREIGHT_TYPE_OPTIONS}
|
|
||||||
value={freightTypeFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setFreightTypeFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All payment statuses"
|
|
||||||
data={PAYMENT_STATUS_OPTIONS}
|
|
||||||
value={paymentStatusFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setPaymentStatusFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 180 }}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Paid, not allocated"
|
|
||||||
checked={paidUnallocated}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPaidUnallocated(e.currentTarget.checked);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
radius="sm"
|
|
||||||
style={{ alignSelf: "center" }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="Gov / Private"
|
|
||||||
data={OWNERSHIP_OPTIONS}
|
|
||||||
value={ownershipFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setOwnershipFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 140 }}
|
|
||||||
/>
|
|
||||||
<DatePickerInput
|
|
||||||
type="range"
|
|
||||||
placeholder="Created date range"
|
|
||||||
value={[createdFrom, createdTo]}
|
|
||||||
onChange={([from, to]) => {
|
|
||||||
setCreatedFrom(from ? new Date(from) : null);
|
|
||||||
setCreatedTo(to ? new Date(to) : null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
presets={getDateRangePresets()}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
/>
|
|
||||||
<DatePickerInput
|
|
||||||
type="range"
|
|
||||||
placeholder="Scheduled date range"
|
|
||||||
value={[scheduledFrom, scheduledTo]}
|
|
||||||
onChange={([from, to]) => {
|
|
||||||
setScheduledFrom(from ? new Date(from) : null);
|
|
||||||
setScheduledTo(to ? new Date(to) : null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
presets={getDateRangePresets()}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 230 }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showEmpty ? (
|
{showEmpty ? (
|
||||||
@@ -791,18 +503,7 @@ export default function BookingRequestsPage() {
|
|||||||
data={rows}
|
data={rows}
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
pagination={{
|
{...controls.tableProps(total)}
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,20 +3,11 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
MultiSelect,
|
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
|
||||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -24,20 +15,18 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
FilterX,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Repeat,
|
Repeat,
|
||||||
Search,
|
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||||
import { FilterToggle } from "@/components/common/FilterToggle";
|
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import {
|
import {
|
||||||
@@ -61,9 +50,9 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||||
|
|
||||||
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
||||||
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
||||||
@@ -112,99 +101,91 @@ const COLUMN_META = {
|
|||||||
cellClassName: "whitespace-normal break-words align-top",
|
cellClassName: "whitespace-normal break-words align-top",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
|
||||||
function startOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(0, 0, 0, 0);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
|
||||||
function endOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(23, 59, 59, 999);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractRequestsPage() {
|
export default function ContractRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
|
||||||
// Filter controls (empty/null = "all").
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
|
||||||
const { filterOptions } = useMyTradeAccess();
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
|
||||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
// Yard options for the route filter (shared routes reference list, same
|
||||||
null,
|
// query BookingRequestsPage uses).
|
||||||
|
const { data: yardRefs } = useQuery(
|
||||||
|
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||||
|
);
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => (yardRefs ?? []).map((y) => ({ value: y.id, label: y.label ?? y.code })),
|
||||||
|
[yardRefs],
|
||||||
);
|
);
|
||||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
|
||||||
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
|
|
||||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
|
||||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
|
||||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
|
||||||
// All filters start empty (no URL params on this page), so collapsed is safe.
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
|
|
||||||
const resetPage = useCallback(() => {
|
// Static shape only (no facet counts) — this is what useFilters needs to
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
// parse the URL and build API params. Counts are attached separately below,
|
||||||
}, [setPagination, pagination.pageSize]);
|
// for rendering only, once the summary query (which itself depends on
|
||||||
|
// these params) has resolved.
|
||||||
|
const filterDefs: FilterDef[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||||
|
{
|
||||||
|
key: "contractKind",
|
||||||
|
label: "Kind",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: CONTRACT_KIND_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tradeDirection",
|
||||||
|
label: "Direction",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "freightType",
|
||||||
|
label: "Freight",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: FREIGHT_TYPE_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paymentCurrency",
|
||||||
|
label: "Currency",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: CURRENCY_OPTIONS,
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
type: "date",
|
||||||
|
secondary: true,
|
||||||
|
// Before/after are safe to expose: the repository applies
|
||||||
|
// createdFrom/createdTo independently, so a single-sided bound
|
||||||
|
// already works server-side.
|
||||||
|
operators: ["between", "before", "after"],
|
||||||
|
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
label: "Route",
|
||||||
|
type: "route",
|
||||||
|
options: yardOptions,
|
||||||
|
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[filterOptions, yardOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const filter: ContractListFilter = useMemo(() => {
|
const controls = useFilters(filterDefs, {
|
||||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
defaultSort: "createdAt:DESC",
|
||||||
return {
|
pageSize: 10,
|
||||||
page: pagination.pageIndex + 1,
|
});
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
sortBy,
|
const filter: ContractListFilter = useMemo(
|
||||||
sortOrder,
|
() => ({
|
||||||
|
...(controls.params as unknown as ContractListFilter),
|
||||||
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
||||||
tab: "all",
|
tab: "all",
|
||||||
// Server-side free-text search (contract reference, customer name).
|
}),
|
||||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
[controls.params],
|
||||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
);
|
||||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
|
||||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
|
||||||
...(kindFilter ? { contractKind: kindFilter } : {}),
|
|
||||||
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
|
|
||||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
|
||||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
pagination.pageIndex,
|
|
||||||
pagination.pageSize,
|
|
||||||
debouncedQuery,
|
|
||||||
statusFilter,
|
|
||||||
directionFilter,
|
|
||||||
freightTypeFilter,
|
|
||||||
kindFilter,
|
|
||||||
currencyFilter,
|
|
||||||
createdFrom,
|
|
||||||
createdTo,
|
|
||||||
sort,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeFilterCount =
|
|
||||||
(statusFilter.length ? 1 : 0) +
|
|
||||||
(directionFilter ? 1 : 0) +
|
|
||||||
(freightTypeFilter ? 1 : 0) +
|
|
||||||
(kindFilter ? 1 : 0) +
|
|
||||||
(currencyFilter ? 1 : 0) +
|
|
||||||
(createdFrom || createdTo ? 1 : 0);
|
|
||||||
|
|
||||||
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
|
||||||
const advancedFilterCount =
|
|
||||||
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
|
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
|
||||||
setStatusFilter([]);
|
|
||||||
setDirectionFilter(null);
|
|
||||||
setFreightTypeFilter(null);
|
|
||||||
setKindFilter(null);
|
|
||||||
setCurrencyFilter(null);
|
|
||||||
setCreatedFrom(null);
|
|
||||||
setCreatedTo(null);
|
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
||||||
}, [setPagination, pagination.pageSize]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } =
|
const { data, isLoading, isError, refetch, isFetching } =
|
||||||
useContractList(filter);
|
useContractList(filter);
|
||||||
@@ -214,13 +195,27 @@ export default function ContractRequestsPage() {
|
|||||||
refetch: refetchSummary,
|
refetch: refetchSummary,
|
||||||
} = useContractListSummary(filter);
|
} = useContractListSummary(filter);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(
|
||||||
|
() => Object.fromEntries((summary?.facets?.status ?? []).map((b) => [b.value, b.count])),
|
||||||
|
[summary?.facets],
|
||||||
|
);
|
||||||
|
|
||||||
|
// filterDefs + counts, for the bar to render. Kept separate from filterDefs
|
||||||
|
// itself so the URL-parsing hook above never has to wait on this query.
|
||||||
|
const defs: FilterDef[] = useMemo(
|
||||||
|
() =>
|
||||||
|
filterDefs.map((d) =>
|
||||||
|
d.key === "statuses" && d.type === "enum" ? { ...d, counts: statusCounts } : d,
|
||||||
|
),
|
||||||
|
[filterDefs, statusCounts],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => (data?.items ?? []).map(toContractListRow),
|
() => (data?.items ?? []).map(toContractListRow),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||||
|
|
||||||
const metrics = summary?.metrics;
|
const metrics = summary?.metrics;
|
||||||
@@ -450,153 +445,14 @@ export default function ContractRequestsPage() {
|
|||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||||
<Stack gap="sm">
|
<FilterBar
|
||||||
<Group gap="sm" wrap="wrap">
|
defs={defs}
|
||||||
<TextInput
|
controls={controls}
|
||||||
placeholder="Search reference or customer…"
|
searchPlaceholder="Search reference or customer…"
|
||||||
leftSection={<Search size={18} />}
|
sortOptions={SORT_OPTIONS}
|
||||||
value={query}
|
viewId="contract-requests"
|
||||||
onChange={(e) => {
|
/>
|
||||||
setQuery(e.target.value);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
rightSection={
|
|
||||||
query && (
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
variant="transparent"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ flex: 1, minWidth: "200px" }}
|
|
||||||
radius="lg"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
data={SORT_OPTIONS}
|
|
||||||
value={sort}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSort(v ?? "createdAt:DESC");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
allowDeselect={false}
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 170 }}
|
|
||||||
aria-label="Sort contracts"
|
|
||||||
/>
|
|
||||||
<MultiSelect
|
|
||||||
placeholder={
|
|
||||||
statusFilter.length ? undefined : "All statuses"
|
|
||||||
}
|
|
||||||
data={STATUS_OPTIONS}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setStatusFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
aria-label="Filter by status"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All kinds"
|
|
||||||
data={CONTRACT_KIND_OPTIONS}
|
|
||||||
value={kindFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setKindFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
aria-label="Filter by contract kind"
|
|
||||||
/>
|
|
||||||
<FilterToggle
|
|
||||||
count={advancedFilterCount}
|
|
||||||
expanded={showAdvanced}
|
|
||||||
onClick={() => setShowAdvanced((v) => !v)}
|
|
||||||
/>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<FilterX size={16} />}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear filters ({activeFilterCount})
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Collapse expanded={showAdvanced}>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<Select
|
|
||||||
placeholder="All directions"
|
|
||||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
|
||||||
value={directionFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setDirectionFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
aria-label="Filter by trade direction"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All freight types"
|
|
||||||
data={FREIGHT_TYPE_OPTIONS}
|
|
||||||
value={freightTypeFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setFreightTypeFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
aria-label="Filter by freight type"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All currencies"
|
|
||||||
data={CURRENCY_OPTIONS}
|
|
||||||
value={currencyFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setCurrencyFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 140 }}
|
|
||||||
aria-label="Filter by payment currency"
|
|
||||||
/>
|
|
||||||
<DatePickerInput
|
|
||||||
type="range"
|
|
||||||
placeholder="Created date range"
|
|
||||||
value={[createdFrom, createdTo]}
|
|
||||||
onChange={([from, to]) => {
|
|
||||||
setCreatedFrom(from ? new Date(from) : null);
|
|
||||||
setCreatedTo(to ? new Date(to) : null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
presets={getDateRangePresets()}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
aria-label="Created date range"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showEmpty ? (
|
{showEmpty ? (
|
||||||
@@ -615,18 +471,7 @@ export default function ContractRequestsPage() {
|
|||||||
isLoading ? "loading" : isError ? "error" : "success"
|
isLoading ? "loading" : isError ? "error" : "success"
|
||||||
}
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
pagination={{
|
{...controls.tableProps(total)}
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
// table-fixed makes the per-column widths stick; without
|
// table-fixed makes the per-column widths stick; without
|
||||||
// it auto-layout re-widens columns once cells wrap.
|
// it auto-layout re-widens columns once cells wrap.
|
||||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -22,10 +19,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Phone,
|
Phone,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Users,
|
Users,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -40,12 +35,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyStatus } from "@/types/customer";
|
import type { Company, CompanyStatus } from "@/types/customer";
|
||||||
import { isOnboardingDraft } from "@/types/customer";
|
import { isOnboardingDraft } from "@/types/customer";
|
||||||
import {
|
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||||
DataTable,
|
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||||
DataTableFooter,
|
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
||||||
@@ -92,28 +83,29 @@ const SORT_OPTIONS = [
|
|||||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** No filter pills — search/sort/page are the only real filter dimensions;
|
||||||
|
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
|
||||||
|
const NO_FILTER_DEFS: FilterDef[] = [];
|
||||||
|
|
||||||
export default function CustomersPage() {
|
export default function CustomersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
|
||||||
const [view, setView] = useState<CustomerView>("all");
|
const [view, setView] = useState<CustomerView>("all");
|
||||||
const [sort, setSort] = useState<string>("review:DESC");
|
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
|
||||||
|
|
||||||
const filter = useMemo(() => {
|
const filter = useMemo(() => {
|
||||||
const [sortBy, sortOrder] = sort.split(":") as [
|
const [sortBy, sortOrder] = controls.sort.split(":") as [
|
||||||
"review" | "name" | "createdAt" | "updatedAt",
|
"review" | "name" | "createdAt" | "updatedAt",
|
||||||
"ASC" | "DESC",
|
"ASC" | "DESC",
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
page: pagination.pageIndex + 1,
|
page: controls.page,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: controls.pageSize,
|
||||||
search: debouncedQuery,
|
search: String(controls.params.search ?? ""),
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
...VIEW_FILTERS[view],
|
...VIEW_FILTERS[view],
|
||||||
};
|
};
|
||||||
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
|
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
|
||||||
|
|
||||||
const { data: stats } = useQuery(
|
const { data: stats } = useQuery(
|
||||||
api.customers.stats.queryOptions({ input: {} }),
|
api.customers.stats.queryOptions({ input: {} }),
|
||||||
@@ -125,7 +117,6 @@ export default function CustomersPage() {
|
|||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
|
|
||||||
const columns: ColumnDef<Company>[] = useMemo(
|
const columns: ColumnDef<Company>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -295,35 +286,24 @@ export default function CustomersPage() {
|
|||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<FilterBar
|
||||||
<TextInput
|
defs={NO_FILTER_DEFS}
|
||||||
placeholder="Search by company, TIN, email or profile reference…"
|
controls={controls}
|
||||||
leftSection={<Search size={18} />}
|
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||||
value={query}
|
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
viewId="customers"
|
||||||
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
|
<SegmentedControl
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
value={view}
|
value={view}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
|
// `view` lives outside useFilters (it's a tab, not a
|
||||||
|
// filter pill), so switching it needs its own page reset —
|
||||||
|
// the same "stranded on page 5" hazard useFilters guards
|
||||||
|
// against for its own filters.
|
||||||
setView(v as CustomerView);
|
setView(v as CustomerView);
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
controls.setPage(1);
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ label: "All", value: "all" },
|
{ label: "All", value: "all" },
|
||||||
@@ -333,21 +313,7 @@ export default function CustomersPage() {
|
|||||||
{ label: "Active", value: "active" },
|
{ label: "Active", value: "active" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select
|
</FilterBar>
|
||||||
size="sm"
|
|
||||||
radius="md"
|
|
||||||
w={160}
|
|
||||||
allowDeselect={false}
|
|
||||||
aria-label="Sort customers"
|
|
||||||
value={sort}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (!v) return;
|
|
||||||
setSort(v);
|
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
|
||||||
}}
|
|
||||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
@@ -358,7 +324,7 @@ export default function CustomersPage() {
|
|||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
debouncedQuery
|
controls.searchText
|
||||||
? "No companies match your search."
|
? "No companies match your search."
|
||||||
: "No companies yet."
|
: "No companies yet."
|
||||||
}
|
}
|
||||||
@@ -370,18 +336,7 @@ export default function CustomersPage() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pagination={{
|
{...controls.tableProps(total)}
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Plus, AlertTriangle } from "lucide-react";
|
import { Plus, AlertTriangle } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import ListControls from "@/components/common/ListControls";
|
|
||||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||||
// despite the ruleEngine path.
|
// despite the ruleEngine path.
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import {
|
||||||
|
applyClientFilters,
|
||||||
|
FilterBar,
|
||||||
|
toRuleEngineFooterProps,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
} from "@/components/filters";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
complianceService,
|
complianceService,
|
||||||
@@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => {
|
|||||||
const formatDate = (value?: string | null) =>
|
const formatDate = (value?: string | null) =>
|
||||||
value ? new Date(value).toLocaleDateString() : "—";
|
value ? new Date(value).toLocaleDateString() : "—";
|
||||||
|
|
||||||
|
const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }];
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
vehicleId: "",
|
vehicleId: "",
|
||||||
type: "INSPECTION" as ComplianceType,
|
type: "INSPECTION" as ComplianceType,
|
||||||
@@ -91,10 +98,18 @@ export default function CompliancePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = useListControls(records as ComplianceRecord[], {
|
const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 });
|
||||||
searchKeys: ["type", "status", "documentNumber"],
|
const filteredRecords = applyClientFilters(
|
||||||
dateKey: "expiryDate",
|
records as ComplianceRecord[],
|
||||||
});
|
COMPLIANCE_FILTER_DEFS,
|
||||||
|
controls.values,
|
||||||
|
controls.searchText,
|
||||||
|
{ searchKeys: ["type", "status", "documentNumber"] },
|
||||||
|
);
|
||||||
|
const pagedRecords = filteredRecords.slice(
|
||||||
|
(controls.page - 1) * controls.pageSize,
|
||||||
|
controls.page * controls.pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: typeof formData) => {
|
mutationFn: async (data: typeof formData) => {
|
||||||
@@ -220,17 +235,11 @@ export default function CompliancePage() {
|
|||||||
Compliance Records
|
Compliance Records
|
||||||
</Title>
|
</Title>
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={COMPLIANCE_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search type, status, document no…"
|
searchPlaceholder="Search type, status, document no…"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="fleet-compliance"
|
||||||
onDateFromChange={controls.setDateFrom}
|
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Expiry"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
/>
|
/>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -261,7 +270,7 @@ export default function CompliancePage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{controls.pagedRows.map((record) => (
|
{pagedRecords.map((record) => (
|
||||||
<Table.Tr key={record.id}>
|
<Table.Tr key={record.id}>
|
||||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -282,11 +291,8 @@ export default function CompliancePage() {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<RuleEngineListFooter
|
<RuleEngineListFooter
|
||||||
pagination={controls.pagination}
|
|
||||||
pageCount={controls.pageCount}
|
|
||||||
totalCount={controls.totalCount}
|
|
||||||
itemLabel="records"
|
itemLabel="records"
|
||||||
onPaginationChange={controls.setPagination}
|
{...toRuleEngineFooterProps(controls, filteredRecords.length)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit, Eye, Plus, Trash2 } from 'lucide-react';
|
||||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
@@ -44,6 +44,13 @@ import type { Train } from '@/services/trains.service';
|
|||||||
import type { WagonType } from '@/services/wagon-types.service';
|
import type { WagonType } from '@/services/wagon-types.service';
|
||||||
import type { Wagon } from '@/services/wagon.service';
|
import type { Wagon } from '@/services/wagon.service';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||||
|
import {
|
||||||
|
applyClientFilters,
|
||||||
|
FilterBar,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
type FilterOption,
|
||||||
|
} from '@/components/filters';
|
||||||
|
|
||||||
type FormValue = string | number | boolean | string[];
|
type FormValue = string | number | boolean | string[];
|
||||||
|
|
||||||
@@ -86,6 +93,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
|||||||
hideViewAction?: boolean;
|
hideViewAction?: boolean;
|
||||||
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
|
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
|
||||||
rowActions?: (item: T) => React.ReactNode;
|
rowActions?: (item: T) => React.ReactNode;
|
||||||
|
/** Enables the Status filter pill; the item's `status` field is matched against these. */
|
||||||
|
statusOptions?: FilterOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||||
@@ -172,9 +181,16 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
removeSuccessMessage,
|
removeSuccessMessage,
|
||||||
hideViewAction = false,
|
hideViewAction = false,
|
||||||
rowActions,
|
rowActions,
|
||||||
|
statusOptions,
|
||||||
}: FleetCrudPageProps<T>) {
|
}: FleetCrudPageProps<T>) {
|
||||||
const [search, setSearch] = useState('');
|
const filterDefs: FilterDef[] = useMemo(
|
||||||
const [page, setPage] = useState(1);
|
() =>
|
||||||
|
statusOptions
|
||||||
|
? [{ key: 'status', label: 'Status', type: 'enum', multiple: false, options: statusOptions }]
|
||||||
|
: [],
|
||||||
|
[statusOptions],
|
||||||
|
);
|
||||||
|
const controls = useFilters(filterDefs, { pageSize: 10 });
|
||||||
const [sortKey, setSortKey] = useState<string>('');
|
const [sortKey, setSortKey] = useState<string>('');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
@@ -184,11 +200,13 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(
|
||||||
const query = search.trim().toLowerCase();
|
() =>
|
||||||
if (!query) return data ?? [];
|
applyClientFilters(data ?? [], filterDefs, controls.values, controls.searchText, {
|
||||||
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
|
searchValue: searchText,
|
||||||
}, [data, search, searchText]);
|
}),
|
||||||
|
[data, filterDefs, controls.values, controls.searchText, searchText],
|
||||||
|
);
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (!sortKey) return filtered;
|
if (!sortKey) return filtered;
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -198,12 +216,13 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
return sortDirection === 'asc' ? result : -result;
|
return sortDirection === 'asc' ? result : -result;
|
||||||
});
|
});
|
||||||
}, [filtered, sortDirection, sortKey]);
|
}, [filtered, sortDirection, sortKey]);
|
||||||
const pageSize = 10;
|
const pageSize = controls.pageSize;
|
||||||
|
const page = controls.page;
|
||||||
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
const toggleSort = (key: string) => {
|
const toggleSort = (key: string) => {
|
||||||
setPage(1);
|
controls.setPage(1);
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||||
return;
|
return;
|
||||||
@@ -298,18 +317,11 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
|
<FilterBar
|
||||||
<Search className="size-4 text-muted-foreground" />
|
defs={filterDefs}
|
||||||
<Input
|
controls={controls}
|
||||||
className="border-0 px-0 shadow-none focus-visible:ring-0"
|
searchPlaceholder={`Search ${title.toLowerCase()}`}
|
||||||
placeholder={`Search ${title.toLowerCase()}`}
|
/>
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border bg-card">
|
<div className="overflow-hidden rounded-lg border bg-card">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -379,10 +391,10 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
|
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
|
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => controls.setPage(page - 1)}>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
|
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => controls.setPage(page + 1)}>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'
|
|||||||
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
|
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
|
||||||
options.find((option) => option.value === value)?.label ?? value ?? '-';
|
options.find((option) => option.value === value)?.label ?? value ?? '-';
|
||||||
|
|
||||||
|
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'SCHEDULED', label: 'Scheduled' },
|
||||||
|
{ value: 'IN_SERVICE', label: 'In service' },
|
||||||
|
{ value: 'UNDER_MAINTENANCE', label: 'Under maintenance' },
|
||||||
|
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||||
|
{ value: 'DEACTIVATED', label: 'Deactivated' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WAGON_STATUS_OPTIONS: FilterOption[] = [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'IMPORT_READY', label: 'Import ready' },
|
||||||
|
{ value: 'EXPORT_READY', label: 'Export ready' },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
|
{ value: 'DETAINED', label: 'Detained' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
|
{ value: 'IN_TRANSIT', label: 'In transit' },
|
||||||
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
|
{ value: 'DAMAGED', label: 'Damaged' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CARGO_STATUS_OPTIONS: FilterOption[] = [
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
|
{ value: 'IN_TRANSIT', label: 'In transit' },
|
||||||
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
|
{ value: 'UNLOADED', label: 'Unloaded' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCOMOTIVE_STATUS_OPTIONS: FilterOption[] = [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
|
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||||
|
];
|
||||||
|
|
||||||
export function TrainMasterDataPage() {
|
export function TrainMasterDataPage() {
|
||||||
const query = useQuery(api.trains.list.queryOptions());
|
const query = useQuery(api.trains.list.queryOptions());
|
||||||
return (
|
return (
|
||||||
@@ -499,6 +552,7 @@ export function TrainMasterDataPage() {
|
|||||||
create={useMutation(api.trains.create.mutationOptions())}
|
create={useMutation(api.trains.create.mutationOptions())}
|
||||||
update={useMutation(api.trains.update.mutationOptions())}
|
update={useMutation(api.trains.update.mutationOptions())}
|
||||||
remove={useMutation(api.trains.remove.mutationOptions())}
|
remove={useMutation(api.trains.remove.mutationOptions())}
|
||||||
|
statusOptions={TRAIN_STATUS_OPTIONS}
|
||||||
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
|
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'code', label: 'Code' },
|
{ key: 'code', label: 'Code' },
|
||||||
@@ -522,14 +576,17 @@ export function TrainMasterDataPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WAGON_TYPE_FILTER_DEFS: FilterDef[] = [
|
||||||
|
{ key: 'isActive', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
|
||||||
|
];
|
||||||
|
|
||||||
export function WagonTypesCrudPage() {
|
export function WagonTypesCrudPage() {
|
||||||
const query = useQuery(api.wagonTypes.list.queryOptions());
|
const query = useQuery(api.wagonTypes.list.queryOptions());
|
||||||
const create = useMutation(api.wagonTypes.create.mutationOptions());
|
const create = useMutation(api.wagonTypes.create.mutationOptions());
|
||||||
const update = useMutation(api.wagonTypes.update.mutationOptions());
|
const update = useMutation(api.wagonTypes.update.mutationOptions());
|
||||||
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
|
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [search, setSearch] = useState('');
|
const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 });
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
|
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
@@ -546,18 +603,15 @@ export function WagonTypesCrudPage() {
|
|||||||
});
|
});
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const pageSize = 10;
|
const pageSize = controls.pageSize;
|
||||||
const filtered = useMemo(() => {
|
const page = controls.page;
|
||||||
const queryText = search.trim().toLowerCase();
|
const filtered = useMemo(
|
||||||
const rows = query.data ?? [];
|
() =>
|
||||||
if (!queryText) return rows;
|
applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, {
|
||||||
return rows.filter((type) =>
|
searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '),
|
||||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
|
}),
|
||||||
.join(' ')
|
[query.data, controls.values, controls.searchText],
|
||||||
.toLowerCase()
|
);
|
||||||
.includes(queryText),
|
|
||||||
);
|
|
||||||
}, [query.data, search]);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...filtered].sort((left, right) => {
|
return [...filtered].sort((left, right) => {
|
||||||
@@ -573,7 +627,7 @@ export function WagonTypesCrudPage() {
|
|||||||
const isSaving = create.isPending || update.isPending;
|
const isSaving = create.isPending || update.isPending;
|
||||||
|
|
||||||
const toggleSort = (key: keyof WagonType) => {
|
const toggleSort = (key: keyof WagonType) => {
|
||||||
setPage(1);
|
controls.setPage(1);
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||||
return;
|
return;
|
||||||
@@ -689,16 +743,7 @@ export function WagonTypesCrudPage() {
|
|||||||
</MantineButton>
|
</MantineButton>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<TextInput
|
<FilterBar defs={WAGON_TYPE_FILTER_DEFS} controls={controls} searchPlaceholder="Search wagon types" />
|
||||||
maw={420}
|
|
||||||
leftSection={<Search size={16} />}
|
|
||||||
placeholder="Search wagon types"
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.currentTarget.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Paper withBorder radius="md">
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
@@ -789,7 +834,7 @@ export function WagonTypesCrudPage() {
|
|||||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
|
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
|
||||||
{sorted.length}
|
{sorted.length}
|
||||||
</Text>
|
</Text>
|
||||||
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
|
<Pagination total={pageCount} value={page} onChange={controls.setPage} size="sm" />
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -906,6 +951,7 @@ export function WagonsCrudPage() {
|
|||||||
create={useMutation(api.wagons.create.mutationOptions())}
|
create={useMutation(api.wagons.create.mutationOptions())}
|
||||||
update={useMutation(api.wagons.update.mutationOptions())}
|
update={useMutation(api.wagons.update.mutationOptions())}
|
||||||
remove={useMutation(api.wagons.remove.mutationOptions())}
|
remove={useMutation(api.wagons.remove.mutationOptions())}
|
||||||
|
statusOptions={WAGON_STATUS_OPTIONS}
|
||||||
searchText={(wagon) => [
|
searchText={(wagon) => [
|
||||||
wagon.wagonNumber,
|
wagon.wagonNumber,
|
||||||
wagon.wagonTypeId,
|
wagon.wagonTypeId,
|
||||||
@@ -959,14 +1005,7 @@ export function WagonsCrudPage() {
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: WAGON_STATUS_OPTIONS,
|
||||||
{ value: 'AVAILABLE', label: 'Available' },
|
|
||||||
{ value: 'IMPORT_READY', label: 'Import ready' },
|
|
||||||
{ value: 'EXPORT_READY', label: 'Export ready' },
|
|
||||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
|
||||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
|
||||||
{ value: 'DETAINED', label: 'Detained' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ key: 'notes', label: 'Notes' },
|
||||||
]}
|
]}
|
||||||
@@ -999,6 +1038,7 @@ export function ContainersCrudPage() {
|
|||||||
create={useMutation(api.containers.create.mutationOptions())}
|
create={useMutation(api.containers.create.mutationOptions())}
|
||||||
update={useMutation(api.containers.update.mutationOptions())}
|
update={useMutation(api.containers.update.mutationOptions())}
|
||||||
remove={useMutation(api.containers.remove.mutationOptions())}
|
remove={useMutation(api.containers.remove.mutationOptions())}
|
||||||
|
statusOptions={CONTAINER_STATUS_OPTIONS}
|
||||||
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
|
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'containerNumber', label: 'Number' },
|
{ key: 'containerNumber', label: 'Number' },
|
||||||
@@ -1058,6 +1098,7 @@ export function CargoesCrudPage() {
|
|||||||
create={useMutation(api.cargoes.create.mutationOptions())}
|
create={useMutation(api.cargoes.create.mutationOptions())}
|
||||||
update={useMutation(api.cargoes.update.mutationOptions())}
|
update={useMutation(api.cargoes.update.mutationOptions())}
|
||||||
remove={useMutation(api.cargoes.remove.mutationOptions())}
|
remove={useMutation(api.cargoes.remove.mutationOptions())}
|
||||||
|
statusOptions={CARGO_STATUS_OPTIONS}
|
||||||
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
|
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'cargoReference', label: 'Reference' },
|
{ key: 'cargoReference', label: 'Reference' },
|
||||||
@@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() {
|
|||||||
removeActionLabel="Decommission"
|
removeActionLabel="Decommission"
|
||||||
removeConfirmMessage="Decommission this locomotive?"
|
removeConfirmMessage="Decommission this locomotive?"
|
||||||
removeSuccessMessage="Locomotive decommissioned"
|
removeSuccessMessage="Locomotive decommissioned"
|
||||||
|
statusOptions={LOCOMOTIVE_STATUS_OPTIONS}
|
||||||
searchText={(locomotive) =>
|
searchText={(locomotive) =>
|
||||||
[
|
[
|
||||||
locomotive.code,
|
locomotive.code,
|
||||||
@@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() {
|
|||||||
label: 'Status',
|
label: 'Status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
required: true,
|
required: true,
|
||||||
options: [
|
options: LOCOMOTIVE_STATUS_OPTIONS,
|
||||||
{ value: 'AVAILABLE', label: 'Available' },
|
|
||||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
|
||||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
|
||||||
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import ListControls from "@/components/common/ListControls";
|
|
||||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||||
// despite the ruleEngine path.
|
// despite the ruleEngine path.
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import {
|
||||||
|
applyClientFilters,
|
||||||
|
FilterBar,
|
||||||
|
toRuleEngineFooterProps,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
} from "@/components/filters";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
@@ -45,6 +50,8 @@ interface FuelPurchase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const FUEL_FILTER_DEFS: FilterDef[] = [{ key: "purchaseDate", label: "Purchased", type: "date" }];
|
||||||
|
|
||||||
export default function FuelPurchasePage() {
|
export default function FuelPurchasePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -123,10 +130,18 @@ export default function FuelPurchasePage() {
|
|||||||
const totalCost = formData.liters * formData.costPerLiter;
|
const totalCost = formData.liters * formData.costPerLiter;
|
||||||
|
|
||||||
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
||||||
const controls = useListControls(purchasesData as FuelPurchase[], {
|
const controls = useFilters(FUEL_FILTER_DEFS, { pageSize: 10 });
|
||||||
searchKeys: ["fuelStation", "paymentMethod"],
|
const filteredPurchases = applyClientFilters(
|
||||||
dateKey: "purchaseDate",
|
purchasesData as FuelPurchase[],
|
||||||
});
|
FUEL_FILTER_DEFS,
|
||||||
|
controls.values,
|
||||||
|
controls.searchText,
|
||||||
|
{ searchKeys: ["fuelStation", "paymentMethod"] },
|
||||||
|
);
|
||||||
|
const pagedPurchases = filteredPurchases.slice(
|
||||||
|
(controls.page - 1) * controls.pageSize,
|
||||||
|
controls.page * controls.pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
||||||
(sum, p) => sum + Number(p.liters),
|
(sum, p) => sum + Number(p.liters),
|
||||||
@@ -195,17 +210,11 @@ export default function FuelPurchasePage() {
|
|||||||
|
|
||||||
{/* Purchases Table */}
|
{/* Purchases Table */}
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={FUEL_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search station or payment method…"
|
searchPlaceholder="Search station or payment method…"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="fleet-fuel-purchases"
|
||||||
onDateFromChange={controls.setDateFrom}
|
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Purchased"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
/>
|
/>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -237,7 +246,7 @@ export default function FuelPurchasePage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{controls.pagedRows.map((purchase) => (
|
{pagedPurchases.map((purchase) => (
|
||||||
<Table.Tr key={purchase.id}>
|
<Table.Tr key={purchase.id}>
|
||||||
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
||||||
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||||
@@ -253,11 +262,8 @@ export default function FuelPurchasePage() {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<RuleEngineListFooter
|
<RuleEngineListFooter
|
||||||
pagination={controls.pagination}
|
|
||||||
pageCount={controls.pageCount}
|
|
||||||
totalCount={controls.totalCount}
|
|
||||||
itemLabel="purchases"
|
itemLabel="purchases"
|
||||||
onPaginationChange={controls.setPagination}
|
{...toRuleEngineFooterProps(controls, filteredPurchases.length)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import ListControls from "@/components/common/ListControls";
|
|
||||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||||
// despite the ruleEngine path.
|
// despite the ruleEngine path.
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import {
|
||||||
|
applyClientFilters,
|
||||||
|
FilterBar,
|
||||||
|
toRuleEngineFooterProps,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
} from "@/components/filters";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
incidentsService,
|
incidentsService,
|
||||||
@@ -89,6 +94,8 @@ const initialForm = {
|
|||||||
reportedBy: "",
|
reportedBy: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const INCIDENT_FILTER_DEFS: FilterDef[] = [{ key: "occurredAt", label: "Occurred", type: "date" }];
|
||||||
|
|
||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -166,10 +173,18 @@ export default function IncidentsPage() {
|
|||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const incidents = incidentsData as Incident[];
|
const incidents = incidentsData as Incident[];
|
||||||
const controls = useListControls(incidents, {
|
const controls = useFilters(INCIDENT_FILTER_DEFS, { pageSize: 10 });
|
||||||
searchKeys: ["type", "severity", "status"],
|
const filteredIncidents = applyClientFilters(
|
||||||
dateKey: "occurredAt",
|
incidents,
|
||||||
});
|
INCIDENT_FILTER_DEFS,
|
||||||
|
controls.values,
|
||||||
|
controls.searchText,
|
||||||
|
{ searchKeys: ["type", "severity", "status"] },
|
||||||
|
);
|
||||||
|
const pagedIncidents = filteredIncidents.slice(
|
||||||
|
(controls.page - 1) * controls.pageSize,
|
||||||
|
controls.page * controls.pageSize,
|
||||||
|
);
|
||||||
const totalCount = incidents.length;
|
const totalCount = incidents.length;
|
||||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||||
@@ -246,17 +261,11 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
{/* Incidents Table */}
|
{/* Incidents Table */}
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={INCIDENT_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search type, severity, status…"
|
searchPlaceholder="Search type, severity, status…"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="fleet-incidents"
|
||||||
onDateFromChange={controls.setDateFrom}
|
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Occurred"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
/>
|
/>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -288,7 +297,7 @@ export default function IncidentsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{controls.pagedRows.map((incident) => (
|
{pagedIncidents.map((incident) => (
|
||||||
<Table.Tr key={incident.id}>
|
<Table.Tr key={incident.id}>
|
||||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -316,11 +325,8 @@ export default function IncidentsPage() {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<RuleEngineListFooter
|
<RuleEngineListFooter
|
||||||
pagination={controls.pagination}
|
|
||||||
pageCount={controls.pageCount}
|
|
||||||
totalCount={controls.totalCount}
|
|
||||||
itemLabel="incidents"
|
itemLabel="incidents"
|
||||||
onPaginationChange={controls.setPagination}
|
{...toRuleEngineFooterProps(controls, filteredIncidents.length)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ export default function InvoiceDetailPage() {
|
|||||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const { data: invoice, isLoading } = useQuery(
|
const { data: invoice, isLoading } = useQuery(
|
||||||
@@ -176,12 +179,27 @@ export default function InvoiceDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadDocument = async () => {
|
const downloadDocument = async (format?: "a4" | "thermal") => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await invoicesService.downloadDocument(id);
|
const { data } = await invoicesService.downloadDocument(id, format);
|
||||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
const suffix = format === "thermal" ? "-thermal" : "";
|
||||||
|
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
|
||||||
|
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
|
||||||
|
// let it become a silent unhandled rejection with just a spinner stopping.
|
||||||
|
toast({
|
||||||
|
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
|
||||||
|
description:
|
||||||
|
format === "thermal"
|
||||||
|
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
@@ -224,17 +242,34 @@ export default function InvoiceDetailPage() {
|
|||||||
subtitle={humanize(invoice.source)}
|
subtitle={humanize(invoice.source)}
|
||||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<Menu position="bottom-end" withinPortal>
|
||||||
variant="default"
|
<Menu.Target>
|
||||||
size="lg"
|
<ActionIcon
|
||||||
radius="md"
|
variant="default"
|
||||||
aria-label="Download invoice"
|
size="lg"
|
||||||
disabled={!canExport}
|
radius="md"
|
||||||
loading={downloading}
|
aria-label="Download invoice"
|
||||||
onClick={() => void downloadDocument()}
|
disabled={!canExport}
|
||||||
>
|
loading={downloading}
|
||||||
<Download size={16} />
|
>
|
||||||
</ActionIcon>
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
onClick={() => void downloadDocument("a4")}
|
||||||
|
>
|
||||||
|
Download PDF (A4)
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Printer size={14} />}
|
||||||
|
onClick={() => void downloadDocument("thermal")}
|
||||||
|
>
|
||||||
|
Download thermal invoice (80mm)
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Badge, Card, Group, Text } from '@mantine/core';
|
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
import ListControls from '@/components/common/ListControls';
|
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
|
||||||
import { useListControls } from '@/hooks/useListControls';
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -63,16 +62,27 @@ const columns: ColumnDef<Loading>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LOADING_FILTER_DEFS: FilterDef[] = [{ key: 'loadedAt', label: 'Loaded', type: 'date' }];
|
||||||
|
|
||||||
/** Record of every inventory item loaded onto a wagon. */
|
/** Record of every inventory item loaded onto a wagon. */
|
||||||
export default function LoadedInventoryPage() {
|
export default function LoadedInventoryPage() {
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
api.warehouses.loadings.queryOptions({ input: {} }),
|
api.warehouses.loadings.queryOptions({ input: {} }),
|
||||||
);
|
);
|
||||||
const loadings = data ?? [];
|
const loadings = data ?? [];
|
||||||
const controls = useListControls(loadings, {
|
const controls = useFilters(LOADING_FILTER_DEFS, { pageSize: 10 });
|
||||||
searchKeys: ['wagonNumber'],
|
// Endpoint takes no params at all — everything filters client-side.
|
||||||
dateKey: 'loadedAt',
|
const filteredLoadings = applyClientFilters(
|
||||||
});
|
loadings,
|
||||||
|
LOADING_FILTER_DEFS,
|
||||||
|
controls.values,
|
||||||
|
controls.searchText,
|
||||||
|
{ searchKeys: ['wagonNumber'] },
|
||||||
|
);
|
||||||
|
const pagedLoadings = filteredLoadings.slice(
|
||||||
|
(controls.page - 1) * controls.pageSize,
|
||||||
|
controls.page * controls.pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -90,24 +100,18 @@ export default function LoadedInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={LOADING_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search by wagon…"
|
searchPlaceholder="Search by wagon…"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="loaded-inventory"
|
||||||
onDateFromChange={controls.setDateFrom}
|
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Loaded"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={controls.pagedRows}
|
data={pagedLoadings}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 shadow-none"
|
||||||
{...controls.tableProps}
|
{...controls.tableProps(filteredLoadings.length)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import ListControls from "@/components/common/ListControls";
|
|
||||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import {
|
||||||
|
applyClientFilters,
|
||||||
|
FilterBar,
|
||||||
|
toRuleEngineFooterProps,
|
||||||
|
useFilters,
|
||||||
|
type FilterDef,
|
||||||
|
} from "@/components/filters";
|
||||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||||
import type { TruckOnSite } from "@/types/warehouse";
|
import type { TruckOnSite } from "@/types/warehouse";
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
@@ -142,10 +147,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRUCK_FILTER_DEFS: FilterDef[] = [{ key: "arrivedAt", label: "Arrived", type: "date" }];
|
||||||
|
|
||||||
export default function TrucksOnSitePage() {
|
export default function TrucksOnSitePage() {
|
||||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
||||||
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
|
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
|
||||||
// deep-links here with ?scope=ON_SITE to land on the matching tab.
|
// deep-links here with ?scope=ON_SITE to land on the matching tab. Read
|
||||||
|
// once at mount, same as before — scope/source stay page-level tab state
|
||||||
|
// (mutually exclusive, dashboard-linked), not filter pills.
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const scopeParam = searchParams.get("scope");
|
const scopeParam = searchParams.get("scope");
|
||||||
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
|
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
|
||||||
@@ -153,7 +162,7 @@ export default function TrucksOnSitePage() {
|
|||||||
);
|
);
|
||||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||||
|
|
||||||
// Scope/source are page filters and run first; the shared control then does
|
// Scope/source are page filters and run first; the filter bar then does
|
||||||
// search + arrival-date range + pagination over what they leave.
|
// search + arrival-date range + pagination over what they leave.
|
||||||
const scoped = useMemo(
|
const scoped = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -163,10 +172,14 @@ export default function TrucksOnSitePage() {
|
|||||||
[trucks, scope, source],
|
[trucks, scope, source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const controls = useListControls(scoped, {
|
const controls = useFilters(TRUCK_FILTER_DEFS, { pageSize: 10 });
|
||||||
|
const filteredTrucks = applyClientFilters(scoped, TRUCK_FILTER_DEFS, controls.values, controls.searchText, {
|
||||||
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
||||||
dateKey: "arrivedAt",
|
|
||||||
});
|
});
|
||||||
|
const pagedTrucks = filteredTrucks.slice(
|
||||||
|
(controls.page - 1) * controls.pageSize,
|
||||||
|
controls.page * controls.pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
||||||
const inboundCount = trucks.length - onSiteCount;
|
const inboundCount = trucks.length - onSiteCount;
|
||||||
@@ -185,7 +198,10 @@ export default function TrucksOnSitePage() {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={scope}
|
value={scope}
|
||||||
onChange={(v) => setScope(v as typeof scope)}
|
onChange={(v) => {
|
||||||
|
setScope(v as typeof scope);
|
||||||
|
controls.setPage(1);
|
||||||
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ label: `All (${trucks.length})`, value: "ALL" },
|
{ label: `All (${trucks.length})`, value: "ALL" },
|
||||||
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
|
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
|
||||||
@@ -195,7 +211,10 @@ export default function TrucksOnSitePage() {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(v) => setSource(v as typeof source)}
|
onChange={(v) => {
|
||||||
|
setSource(v as typeof source);
|
||||||
|
controls.setPage(1);
|
||||||
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ label: "All", value: "ALL" },
|
{ label: "All", value: "ALL" },
|
||||||
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
|
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
|
||||||
@@ -205,30 +224,21 @@ export default function TrucksOnSitePage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={TRUCK_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Plate, driver, booking, container…"
|
searchPlaceholder="Plate, driver, booking, container…"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="trucks-on-site"
|
||||||
onDateFromChange={controls.setDateFrom}
|
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Arrived"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Text size="sm">Loading…</Text>
|
<Text size="sm">Loading…</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Rows rows={controls.pagedRows} />
|
<Rows rows={pagedTrucks} />
|
||||||
<RuleEngineListFooter
|
<RuleEngineListFooter
|
||||||
pagination={controls.pagination}
|
|
||||||
pageCount={controls.pageCount}
|
|
||||||
totalCount={controls.totalCount}
|
|
||||||
itemLabel="trucks"
|
itemLabel="trucks"
|
||||||
onPaginationChange={controls.setPagination}
|
{...toRuleEngineFooterProps(controls, filteredTrucks.length)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -18,8 +18,7 @@ import {
|
|||||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
import ListControls from '@/components/common/ListControls';
|
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
|
||||||
import { useListControls } from '@/hooks/useListControls';
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { AccrualDashboard } from '@/components/warehouses';
|
import { AccrualDashboard } from '@/components/warehouses';
|
||||||
@@ -54,9 +53,21 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
|||||||
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
||||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||||
|
|
||||||
|
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'enum',
|
||||||
|
multiple: false,
|
||||||
|
options: WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') })),
|
||||||
|
},
|
||||||
|
{ key: 'issuedAt', label: 'Issued', type: 'date' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function WarehouseInvoicesPage() {
|
export default function WarehouseInvoicesPage() {
|
||||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const controls = useFilters(INVOICE_FILTER_DEFS, { pageSize: 10 });
|
||||||
|
const status = (controls.values.status?.v[0] as WarehouseInvoiceStatus | undefined) ?? null;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
api.warehouses.invoices.queryOptions({
|
api.warehouses.invoices.queryOptions({
|
||||||
@@ -65,10 +76,20 @@ export default function WarehouseInvoicesPage() {
|
|||||||
);
|
);
|
||||||
const invoices = data ?? [];
|
const invoices = data ?? [];
|
||||||
|
|
||||||
const controls = useListControls(invoices, {
|
// Endpoint only filters by `status`; search + issued-date range are
|
||||||
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
|
// applied client-side (the client bridge — see components/filters/clientFilter.ts).
|
||||||
dateKey: 'issuedAt',
|
// Flip to server mode by deleting this call once the endpoint takes more params.
|
||||||
});
|
const filteredInvoices = applyClientFilters(
|
||||||
|
invoices,
|
||||||
|
INVOICE_FILTER_DEFS,
|
||||||
|
controls.values,
|
||||||
|
controls.searchText,
|
||||||
|
{ searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'] },
|
||||||
|
);
|
||||||
|
const pagedInvoices = useMemo(
|
||||||
|
() => filteredInvoices.slice((controls.page - 1) * controls.pageSize, controls.page * controls.pageSize),
|
||||||
|
[filteredInvoices, controls.page, controls.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
||||||
{
|
{
|
||||||
@@ -135,36 +156,20 @@ export default function WarehouseInvoicesPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<ListControls
|
<FilterBar
|
||||||
search={controls.search}
|
defs={INVOICE_FILTER_DEFS}
|
||||||
onSearchChange={controls.setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search invoice no / booking / customer"
|
searchPlaceholder="Search invoice no / booking / customer"
|
||||||
dateFrom={controls.dateFrom}
|
viewId="warehouse-invoices"
|
||||||
onDateFromChange={controls.setDateFrom}
|
/>
|
||||||
dateTo={controls.dateTo}
|
|
||||||
onDateToChange={controls.setDateTo}
|
|
||||||
dateLabel="Issued"
|
|
||||||
hasFilters={controls.hasFilters}
|
|
||||||
onReset={controls.reset}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
label="Status"
|
|
||||||
placeholder="All statuses"
|
|
||||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
|
||||||
value={status}
|
|
||||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
|
||||||
clearable
|
|
||||||
w={200}
|
|
||||||
/>
|
|
||||||
</ListControls>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={invoiceColumns}
|
columns={invoiceColumns}
|
||||||
data={controls.pagedRows}
|
data={pagedInvoices}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
emptyMessage="No invoices found."
|
emptyMessage="No invoices found."
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 shadow-none"
|
||||||
{...controls.tableProps}
|
{...controls.tableProps(filteredInvoices.length)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ import { customersService } from "./customers.service";
|
|||||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||||
import { eimsService } from "./eims.service";
|
import { eimsService } from "./eims.service";
|
||||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVerifyResult } from "@/types/eims";
|
||||||
import { invoicesService } from "./invoices.service";
|
import { invoicesService } from "./invoices.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -3217,6 +3217,51 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
eimsCancel: endpoint<{ id: string; reasonCode: string; remark?: string }, EimsInvoiceStatusView>(
|
||||||
|
"invoices",
|
||||||
|
"eimsCancel",
|
||||||
|
({ id, reasonCode, remark }) => eimsService.cancel(id, { reasonCode, remark }),
|
||||||
|
undefined,
|
||||||
|
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||||
|
),
|
||||||
|
|
||||||
|
eimsReceipts: endpoint<{ id: string }, EimsReceiptView[]>(
|
||||||
|
"invoices",
|
||||||
|
"eimsReceipts",
|
||||||
|
({ id }) => eimsService.listReceipts(id),
|
||||||
|
({ id }) => QUERY_KEYS.INVOICES.eimsReceipts(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
eimsRegisterSalesReceipt: endpoint<
|
||||||
|
{ id: string; modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||||
|
EimsReceiptView
|
||||||
|
>(
|
||||||
|
"invoices",
|
||||||
|
"eimsRegisterSalesReceipt",
|
||||||
|
({ id, ...input }) => eimsService.registerSalesReceipt(id, input),
|
||||||
|
undefined,
|
||||||
|
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||||
|
),
|
||||||
|
|
||||||
|
eimsRegisterWithholdingReceipt: endpoint<
|
||||||
|
{ id: string; type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||||
|
EimsReceiptView
|
||||||
|
>(
|
||||||
|
"invoices",
|
||||||
|
"eimsRegisterWithholdingReceipt",
|
||||||
|
({ id, ...input }) => eimsService.registerWithholdingReceipt(id, input),
|
||||||
|
undefined,
|
||||||
|
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||||
|
),
|
||||||
|
|
||||||
|
issueMemo: endpoint<{ id: string; type: "CRE" | "DEB"; reason: string }, Invoice>(
|
||||||
|
"invoices",
|
||||||
|
"issueMemo",
|
||||||
|
({ id, ...input }) => invoicesService.issueMemo(id, input),
|
||||||
|
undefined,
|
||||||
|
() => [QUERY_KEYS.INVOICES.ROOT],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export interface ContractListSummaryTabs {
|
|||||||
export interface ContractListSummary {
|
export interface ContractListSummary {
|
||||||
metrics: ContractListSummaryMetrics;
|
metrics: ContractListSummaryMetrics;
|
||||||
tabs: ContractListSummaryTabs;
|
tabs: ContractListSummaryTabs;
|
||||||
|
/**
|
||||||
|
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||||
|
* every OTHER currently-active filter. Optional: pages built before the
|
||||||
|
* pill filter bar don't read it, and it degrades gracefully — an absent
|
||||||
|
* key just means that popover shows no counts.
|
||||||
|
*/
|
||||||
|
facets?: Record<string, { value: string; count: number }[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractView {
|
export interface ContractView {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { api as apiClient } from "@/auth/http";
|
import { api as apiClient } from "@/auth/http";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
import type {
|
||||||
|
EimsInvoiceStatusView,
|
||||||
|
EimsModeOfPayment,
|
||||||
|
EimsReceiptView,
|
||||||
|
EimsVerifyResult,
|
||||||
|
} from "@/types/eims";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MoR EIMS filing actions on an invoice.
|
* MoR EIMS filing actions on an invoice.
|
||||||
@@ -36,4 +41,45 @@ export const eimsService = {
|
|||||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
|
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled one. */
|
||||||
|
cancel(
|
||||||
|
invoiceId: string,
|
||||||
|
input: { reasonCode: string; remark?: string },
|
||||||
|
): Promise<EimsInvoiceStatusView> {
|
||||||
|
return apiClient
|
||||||
|
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.CANCEL(invoiceId), input)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerSalesReceipt(
|
||||||
|
invoiceId: string,
|
||||||
|
input: { modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||||
|
): Promise<EimsReceiptView> {
|
||||||
|
return apiClient
|
||||||
|
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_SALES(invoiceId), input)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerWithholdingReceipt(
|
||||||
|
invoiceId: string,
|
||||||
|
input: { type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||||
|
): Promise<EimsReceiptView> {
|
||||||
|
return apiClient
|
||||||
|
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_WITHHOLDING(invoiceId), input)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
listReceipts(invoiceId: string): Promise<EimsReceiptView[]> {
|
||||||
|
return apiClient
|
||||||
|
.get<EimsReceiptView[]>(URL_CONSTANTS.EIMS.RECEIPTS(invoiceId))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Blob download — same pattern as `invoicesService.downloadDocument`. */
|
||||||
|
downloadReceiptDocument(invoiceId: string, receiptId: string) {
|
||||||
|
return apiClient.get<Blob>(URL_CONSTANTS.EIMS.RECEIPT_DOCUMENT(invoiceId, receiptId), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,12 +41,24 @@ export const invoicesService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadDocument(id: string) {
|
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
|
||||||
|
downloadDocument(id: string, format?: "a4" | "thermal") {
|
||||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
params: format ? { format } : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Issue a credit/debit memo against a registered invoice (MoR DEB/CRE) — filing-equivalent. */
|
||||||
|
issueMemo(
|
||||||
|
id: string,
|
||||||
|
input: { type: "CRE" | "DEB"; reason: string },
|
||||||
|
): Promise<Invoice> {
|
||||||
|
return apiClient
|
||||||
|
.post<Invoice>(URL_CONSTANTS.BILLING.INVOICE_MEMO(id), input)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||||
listOfflineUsd(
|
listOfflineUsd(
|
||||||
filter: InvoiceListFilter,
|
filter: InvoiceListFilter,
|
||||||
|
|||||||
@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
|
|||||||
[section: string]: unknown;
|
[section: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MoR `ModeOfPayment` enum — confirmed by a live schema error, verbatim spelling/casing. */
|
||||||
|
export const EIMS_MODE_OF_PAYMENT = [
|
||||||
|
"CASH",
|
||||||
|
"CHEQUE",
|
||||||
|
"CPO",
|
||||||
|
"Local Bank Transfer",
|
||||||
|
"SWIFT",
|
||||||
|
"Wire Transfer",
|
||||||
|
"Letter of Credit",
|
||||||
|
"Card",
|
||||||
|
] as const;
|
||||||
|
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
type IdentitySubject,
|
type IdentitySubject,
|
||||||
type IdentityVerificationState,
|
type IdentityVerificationState,
|
||||||
} from "@/services/verifayda.service";
|
} from "@/services/verifayda.service";
|
||||||
|
import { isBypassEnv } from "@/utils/dev-bypass";
|
||||||
|
|
||||||
|
/** Sentinel code that skips the real eSignet exchange in dev/staging (see api's DEV_BYPASS_FAYDA_CODE). */
|
||||||
|
const DEV_BYPASS_FAYDA_CODE = "DEV_BYPASS";
|
||||||
|
|
||||||
interface FaydaVerifyPanelProps {
|
interface FaydaVerifyPanelProps {
|
||||||
subject: IdentitySubject;
|
subject: IdentitySubject;
|
||||||
@@ -80,6 +84,23 @@ export default function FaydaVerifyPanel({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Dev/staging only: send the tab straight to /fayda/callback with the
|
||||||
|
// sentinel code instead of round-tripping through eSignet — that page's
|
||||||
|
// existing completeIdentity()/navigate-back logic runs unchanged.
|
||||||
|
if (isBypassEnv()) {
|
||||||
|
stashPendingVerification({
|
||||||
|
subject,
|
||||||
|
returnTo:
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
window.location.hash,
|
||||||
|
});
|
||||||
|
window.location.assign(
|
||||||
|
`/fayda/callback?code=${DEV_BYPASS_FAYDA_CODE}&state=bypass`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authorizationUrl = await verifaydaService.start();
|
const authorizationUrl = await verifaydaService.start();
|
||||||
// Record who is being verified and where to come back to before the tab
|
// Record who is being verified and where to come back to before the tab
|
||||||
// leaves — /fayda/callback has no other way to know either.
|
// leaves — /fayda/callback has no other way to know either.
|
||||||
|
|||||||
4
apps/edr-freight-web/portal/src/utils/dev-bypass.ts
Normal file
4
apps/edr-freight-web/portal/src/utils/dev-bypass.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** True when VITE_ENV is "dev" or "staging" — mirrors the API's ENV gate. */
|
||||||
|
export function isBypassEnv(): boolean {
|
||||||
|
return ["dev", "staging"].includes(import.meta.env.VITE_ENV ?? "");
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_POSTHOG_KEY?: string;
|
readonly VITE_POSTHOG_KEY?: string;
|
||||||
/** Self-hosted PostHog instance URL. */
|
/** Self-hosted PostHog instance URL. */
|
||||||
readonly VITE_POSTHOG_HOST?: string;
|
readonly VITE_POSTHOG_HOST?: string;
|
||||||
|
/** "dev" | "staging" — enables the OTP/payment/Fayda bypass. Unset in prod. */
|
||||||
|
readonly VITE_ENV?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user