Merge pull request #1301 from Tria-plc/dev

freight
This commit is contained in:
marshal
2026-08-15 22:02:01 +03:00
committed by GitHub
140 changed files with 7365 additions and 1625 deletions

View File

@@ -1,4 +1,10 @@
# 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
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
# `application` from this env var directly, bypassing MezgebModule.forRoot's

View File

@@ -0,0 +1,9 @@
import pg from 'pg';
import fs from 'fs';
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
await c.connect();
const sql = process.argv[2];
const r = await c.query(sql);
console.log(JSON.stringify(r.rows,null,1));
await c.end();

View 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";

View File

@@ -41,4 +41,16 @@ export class PaginationQueryDto {
@Transform(({ value }) => String(value).toUpperCase())
@IsIn(['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;
}

View 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);
}

View File

@@ -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,
});
});
});

View File

@@ -83,3 +83,32 @@ export function paginateArray<T>(
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');
}

View File

@@ -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`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
@@ -116,6 +116,22 @@ export class CustomerResetService {
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
@@ -128,51 +144,58 @@ export class CustomerResetService {
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return null;
return [];
}
return this.deliverResetLink(user, user.id, channel, options?.scope);
// Mint once, before any send: a failed send leaves an unused ticket that
// simply expires, whereas sending a link before the ticket exists would
// hand the customer a URL that is dead on arrival.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
continue;
}
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
}
/**
* Shared tail: target selection → SMS reachability → mint → send → report.
* Callers have already resolved `user` to an active account.
* Shared tail: send the already-minted ticket to a resolved target → report.
*/
private async deliverResetLink(
user: User,
target: OtpTarget,
userId: string,
channel: ResetChannel,
ticket: ResetTicket,
scope?: string,
): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email
? await this.emailClient.sendEmail({

View File

@@ -212,7 +212,9 @@ export class ForgotPasswordService {
* is the proof).
*/
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url");
// Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
// no "_" — gateways substitute a space and the link arrives broken.
const code = randomBytes(24).toString("hex");
const verificationCode = await hashPassword(code);
await this.dataSource.transaction(async (manager) => {

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
import { IssueMemoDto } from "./dto/issue-memo.dto";
@ApiTags("billing")
@Controller("billing")
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline,
FREIGHT_PERMS.invoices.memoIssue,
])
@ApiBearerAuth()
export class BillingController {
@@ -63,6 +66,23 @@ export class BillingController {
});
}
@Get("invoices/summary")
@ApiOperation({
summary:
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
})
async collectedSummary(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.collectedSummary({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
@@ -99,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")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
@ApiOperation({
summary:
'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);
}

View File

@@ -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", () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
@@ -774,6 +927,7 @@ describe("BillingService.document", () => {
const build = (invoice: Record<string, unknown>) => {
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(
{} as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
@@ -781,7 +935,7 @@ describe("BillingService.document", () => {
{} as never,
{} as never,
{} as never,
{ render } as never,
{ render, renderThermal } as never,
{} as never,
{
get: (key: string) =>
@@ -790,7 +944,7 @@ describe("BillingService.document", () => {
: undefined,
} as never, // config
);
return { service, render };
return { service, render, renderThermal };
};
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.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();
});
});

View File

@@ -10,7 +10,7 @@ import {
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
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>`. */
export interface InvoiceEventPayload {
invoiceId: string;
@@ -192,6 +206,40 @@ export class BillingService {
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
return qb;
}
async findAllPaginated(
filter: {
companyId?: string;
@@ -215,31 +263,80 @@ export class BillingService {
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount();
return { items, total };
return { items: await this.attachShippingLineCompanies(items), total };
}
/**
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
* entity's doc comment — so this is a second query keyed off the ids
* already loaded, same shape as `company`.
*/
private async attachShippingLineCompanies<T extends Invoice>(
invoices: T[],
): Promise<T[]> {
const ids = [
...new Set(
invoices
.map((i) => i.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return invoices;
const lines = await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(ids) } });
const byId = new Map(lines.map((l) => [l.id, l]));
return invoices.map((invoice) => {
const line = invoice.shippingLineCompanyId
? byId.get(invoice.shippingLineCompanyId)
: undefined;
return line
? ({
...invoice,
shippingLineCompany: {
id: line.id,
name: line.name,
email: line.email,
phoneNumber: line.phoneNumber,
},
} as T)
: invoice;
});
}
/**
* Total collected (`paidAmount`) across every invoice matching the same
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
* invoices summary card reflects the whole filtered set, not just the
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
this.applyInvoiceFilters(qb, filter);
const rows: { currency: string; collected: string }[] =
await qb.getRawMany();
return Object.fromEntries(
rows.map((row) => [row.currency, Number(row.collected) || 0]),
);
}
/**
@@ -388,21 +485,30 @@ export class BillingService {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── 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);
return this.invoiceDocuments.render(
await this.toDocumentModel(invoice, "INVOICE"),
);
const model = 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. */
@@ -418,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. */
private async bookingSummaryRows(
invoice: Invoice,
@@ -542,7 +639,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
};
}
@@ -709,11 +806,16 @@ export class BillingService {
// ── 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, {
table: "freight.invoices",
code: "INV",
code,
});
}
@@ -736,9 +838,123 @@ export class BillingService {
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(
input: GenerateInvoiceInput,
mg: EntityManager,
code = "INV",
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
@@ -786,7 +1002,7 @@ export class BillingService {
(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(
mg.create(Invoice, {

View File

@@ -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");
});
});

View File

@@ -18,6 +18,36 @@ import {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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). */
export interface InvoiceDocumentLine {
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)} &middot; ${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
* unavailable. Mirrors the HTML layout closely enough to pass as the same
@@ -241,18 +382,7 @@ export class InvoiceDocumentService {
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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 date = formatDate;
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
@@ -283,7 +413,7 @@ export class InvoiceDocumentService {
const totalRows = model.totals
.map(
(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("");

View File

@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
}
</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 {
/** Label used in logs to identify the document kind. */
label?: string;
/** Landscape A4 instead of the default portrait — wide tables need it. */
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
* 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;
}
@@ -54,17 +82,31 @@ export class PdfRenderService {
const browser = await puppeteer.default.launch(launchOptions);
try {
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.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: "A4",
landscape: opts.landscape ?? false,
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const pdf = thermal
? await page.pdf({
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
height: `${await this.thermalContentHeightMm(page)}mm`,
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);
if (!this.isValidPdf(buffer)) {
@@ -79,6 +121,15 @@ export class PdfRenderService {
}
} catch (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);
if (this.isValidPdf(fallback)) {
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 {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {

View File

@@ -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[];
}

View File

@@ -212,6 +212,59 @@ describe("toEimsInvoice", () => {
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", () => {
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
/lines sum to 11000 but the invoice total is 9000/,

View File

@@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
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 {
City: string | null;
@@ -60,7 +67,9 @@ export interface EimsDocumentDetails {
DocumentNumber: string;
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
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 {
@@ -212,7 +221,18 @@ export interface EimsMapperContext {
unitDefault: string;
incomeWithholdValue: 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;
/** MoR numeric country code for the buyer; our DB stores the country name. */
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);
if (Number.isNaN(issuedAt.getTime())) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
@@ -430,7 +470,8 @@ export function toEimsInvoice(
DocumentDetails: {
DocumentNumber: context.documentNumber,
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
Type: EIMS_DOCUMENT_TYPE,
Type: documentType,
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
},
ItemList,
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },

View File

@@ -180,4 +180,27 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
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;
}

View File

@@ -0,0 +1,42 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
* partial cut stays proportional.
*/
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
}>;
};
const booking = {
id: 'b1',
freightType: 'BULK',
wagonsRequired: 4,
cargoTotalWeightVgm: 250.5,
bulkTotalWeightTons: null,
};
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
});
it('rejects more wagons than the booking has', async () => {
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('sizes a partial cut proportionally', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
expect(cut.wagons).toBe(1);
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});

View File

@@ -7,8 +7,9 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } from 'typeorm';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
import { BillingService } from '../billing/billing.service';
import { ContractBookingService } from '../contracts/contract-booking.service';
@@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en
import { FirstMileService } from '../first-mile/first-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
@@ -46,10 +49,15 @@ import {
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
* unit so the fee scales with the cancelled wagon count.
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
* + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the
* fee scales with the cancelled wagon count and differs by what was booked.
*/
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
@@ -62,8 +70,20 @@ interface RequestedCut {
quantities: CancelledQuantities;
}
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
interface PricedFee {
amount: number;
currency: string;
/** Effective per-wagon fee (amount / wagons) — one number for the customer. */
perWagon: number;
/** Rate rows used; the first is recorded on the ledger row. */
rates: Rate[];
}
/**
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
* Wagon cancellation on a PAID booking (partial or whole), with a rebooking
* credit. Cutting every wagon ends the source booking CANCELLED at T2; the
* credit then rebooks as a fresh booking under the same contract.
*
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
* T1 request — validate + price the fee, open the fee invoice. Nothing else
@@ -91,6 +111,7 @@ export class BookingWagonCancellationService {
private readonly repo: BookingWagonCancellationsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => ContractBookingService))
private readonly contractBooking: ContractBookingService,
@Inject(forwardRef(() => ClearanceMilestoneService))
@@ -120,14 +141,13 @@ export class BookingWagonCancellationService {
}> {
const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const fee = await this.priceFee(booking, cut);
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue),
feeAmount,
feeCurrency: rate.currency,
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons),
};
}
@@ -146,8 +166,8 @@ export class BookingWagonCancellationService {
}
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
@@ -156,9 +176,11 @@ export class BookingWagonCancellationService {
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: rate.id,
// ponytail: one FK for a mixed-size container cut records the first
// size's rate; the invoice line carries the effective per-wagon fee.
feeRateId: fee.rates[0].id,
feeAmount,
feeCurrency: rate.currency,
feeCurrency: fee.currency,
status: 'FEE_PENDING',
reason: dto.reason ?? null,
requestedByUserId: userId ?? null,
@@ -173,15 +195,15 @@ export class BookingWagonCancellationService {
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: rate.currency,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons,
unitRate: Number(rate.rateValue),
unitRate: fee.perWagon,
amount: feeAmount,
currency: rate.currency,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
@@ -343,12 +365,28 @@ export class BookingWagonCancellationService {
const preSplitQuantities =
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
wagonsRequired: Math.max(0, wagonsLeft),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
),
totalAmount: Math.max(
0,
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
),
isSplit: true,
preSplitQuantities,
...(isFull
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
: {}),
} as never);
await manager.getRepository(BookingWagonCancellation).update(row.id, {
@@ -360,11 +398,15 @@ export class BookingWagonCancellationService {
});
const booking = await this.bookingsRepository.findById(row.bookingId);
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
if (booking) {
const whole = booking.status === 'CANCELLED';
this.notifyCustomer(
booking,
'Wagon cancellation confirmed',
`${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
);
}
this.logger.log(
@@ -372,6 +414,33 @@ export class BookingWagonCancellationService {
);
}
/**
* Whole-booking cut: take the cancelled booking OFF its train entirely —
* schedule link, leftover wagon slots, window status — via the ops unassign
* path (no "removed from train" notice: the customer cancelled it). A stale
* link would keep showing the booking on the schedule AND poison every later
* auto wagon allocation on that train (the whole-train re-plan rejects a
* CANCELLED booking). Then re-run allocation so bookings held back by it
* (e.g. the rebooked credit) get their wagons.
*/
private async detachFromSchedule(booking: Booking): Promise<void> {
const links = await this.dataSource
.getRepository(TrainScheduleBooking)
.find({ where: { bookingId: booking.id } });
for (const link of links) {
try {
await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, {
notifyCustomer: false,
});
await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId);
} catch (err) {
this.logger.error(
`Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── T3: rebook ──────────────────────────────────────────────────────────────
async rebook(
@@ -401,6 +470,8 @@ export class BookingWagonCancellationService {
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
source.contractId,
createDto,
@@ -413,9 +484,13 @@ export class BookingWagonCancellationService {
// The freight is already paid (credit) — mark PAID and let the existing
// paid-booking machinery place it. No invoice is generated for it.
// Its price IS the credit (already paid, in the source currency) — not a
// fresh live-rate quote; a later cut of the rebooked booking credits from it.
await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID',
status: 'PAID',
totalAmount: Number(row.creditAmount),
paymentCurrency: source.paymentCurrency,
});
await this.copyClearanceState(source, newBookingId);
@@ -532,16 +607,16 @@ export class BookingWagonCancellationService {
const live = liveBySize.get(cut.containerSize) ?? 0;
if (cut.quantity > live) {
throw new BadRequestException(
`Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`,
`Cannot cancel ${cut.quantity} × ${sizeFtOf(cut.containerSize)}ft — the booking only has ${live}.`,
);
}
bySize[cut.containerSize] = cut.quantity;
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize));
}
wagons = round2(wagons);
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
@@ -577,9 +652,11 @@ export class BookingWagonCancellationService {
}
}
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
// Whole-booking cut takes the exact total, no ratio rounding.
const weightShare =
wagons >= totalWagons
? round3(Number(booking.cargoTotalWeightVgm))
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
wagons,
weightTons: weightShare,
@@ -594,17 +671,19 @@ export class BookingWagonCancellationService {
if (!wagons || wagons <= 0) {
throw new BadRequestException('Specify how many wagons to cancel.');
}
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Whole-booking cut: all cargo, exactly. Otherwise proportional sizing.
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
// PER_ITEM cancels ever need to be exact per item.
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons);
const isFull = wagons >= totalWagons;
let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? Math.floor(tons) : round3(tons);
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
@@ -641,19 +720,23 @@ export class BookingWagonCancellationService {
}
const wagons = allocations.length;
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
const isFull = wagons >= totalWagons;
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
allocated > 0
// Whole-booking cut takes the exact total; partial takes the wagons'
// allocated tonnage (ratio fallback when nothing is allocated yet).
const tons = isFull
? round3(Number(booking.cargoTotalWeightVgm))
: allocated > 0
? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
@@ -714,21 +797,83 @@ export class BookingWagonCancellationService {
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
private async feeRate(): Promise<Rate> {
const rate = await this.dataSource.getRepository(Rate).findOne({
/**
* Price the cut off the LIVE per-wagon cancellation rates for the booking's
* trade direction. Bulk bills the rate scoped to the booking's commodity ×
* cancelled wagons; a container cut bills each size at its own container
* type's rate × the wagons that size occupies (two 20ft share one). A
* booking owned by a shipping line prices off that line's rates only —
* standard rates are never a fallback, matching booking pricing.
*/
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
const raw = await this.priceFeeInRateCurrency(booking, cut);
// Bill in the booking's own currency (rates are configured in USD; ETB
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
if (from === target) return raw;
const fx = await this.exchangeService.getRate(from, target);
return {
...raw,
amount: round2(raw.amount * fx),
perWagon: round2(raw.perWagon * fx),
currency: target,
};
}
private async priceFeeInRateCurrency(
booking: Booking,
cut: RequestedCut,
): Promise<PricedFee> {
const rates = await this.dataSource.getRepository(Rate).find({
where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON',
status: 'LIVE',
tradeDirection: booking.tradeDirection,
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
},
order: { createdAt: 'DESC' },
});
if (!rate) {
throw new BadRequestException(
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
const missing = (scope: string): BadRequestException =>
new BadRequestException(
`No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`,
);
if (booking.freightType !== 'CONTAINER') {
const rate = rates.find(
(r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId,
);
if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`);
const amount = round2(Number(rate.rateValue) * cut.wagons);
return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] };
}
return rate;
// Container: split the cancelled wagons across sizes in proportion to the
// wagon-space each size's units occupy, so the total always equals
// cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut).
const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0);
const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(sizeFtOf(size));
const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0);
if (!bySize.length || totalSpace <= 0) throw missing('containers');
const containerTypes = await this.dataSource.getRepository(ContainerType).find();
const used: Rate[] = [];
let amount = 0;
let currency = '';
for (const entry of bySize) {
const [size] = entry;
const sizeFt = sizeFtOf(size);
const typeIds = new Set(
containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id),
);
const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId));
if (!rate) throw missing(`${sizeFt || '?'}ft containers`);
currency = rate.currency;
used.push(rate);
amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace);
}
amount = round2(amount);
return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used };
}
/**
@@ -751,7 +896,7 @@ export class BookingWagonCancellationService {
const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0);
if (live < toDrop) {
throw new BadRequestException(
`Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`,
`Booking changed since the request: only ${live} × ${sizeFtOf(size)}ft left, cannot cancel ${toDrop}.`,
);
}
for (const line of lines) {
@@ -795,7 +940,7 @@ export class BookingWagonCancellationService {
});
await manager.getRepository(BookingContainer).update(line.id, {
quantity: qty - drop,
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))),
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length,
reeferQuantity: keptUnits.filter((u) => u.isReefer).length,
@@ -883,7 +1028,7 @@ export class BookingWagonCancellationService {
const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.length,
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))),
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length,
@@ -902,9 +1047,9 @@ export class BookingWagonCancellationService {
booking: Booking,
tons: number,
): Promise<void> {
if (tons >= Number(booking.cargoTotalWeightVgm)) {
if (tons > Number(booking.cargoTotalWeightVgm)) {
throw new BadRequestException(
'Booking changed since the request: the cut no longer leaves any cargo.',
'Booking changed since the request: the cut exceeds the cargo left on the booking.',
);
}
if (booking.bulkTotalWeightTons != null) {

View File

@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService,
BookingsRepository,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,

View File

@@ -12,6 +12,7 @@ import {
SelectQueryBuilder,
} from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
@@ -79,6 +80,8 @@ export interface BookingListFilterOptions {
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
consolidationPaired?: string;
}
@@ -857,6 +860,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>> {
const rows = await this.repository
.createQueryBuilder('booking')
@@ -915,16 +941,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
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(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
omit?: keyof BookingListFilterOptions | 'status',
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
if (omit !== 'status') {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
}
if (options.companyId) {
@@ -957,12 +991,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.bookingType) {
if (omit !== 'bookingType' && options.bookingType) {
// The stored booking_type column is 'ONE_TIME' for every row (contract
// drawdowns included — see contract-booking.service create), so the
// one-time vs general split keys on the denormalized contract_kind:
@@ -1011,12 +1045,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.tradeDirection) {
if (options.customerKind === 'SHIPPING_LINE') {
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
} else if (options.customerKind === 'CUSTOMER') {
qb.andWhere('booking.shipping_line_company_id IS NULL');
}
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
if (omit !== 'tradeDirection' && options.tradeDirections) {
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
@@ -1024,7 +1063,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
paymentCurrency: options.paymentCurrency,
});
}
if (options.paymentStatus) {
if (omit !== 'paymentStatus' && options.paymentStatus) {
qb.andWhere('booking.payment_status = :paymentStatus', {
paymentStatus: options.paymentStatus,
});

View File

@@ -1766,6 +1766,38 @@ export class BookingsService {
pending.has(b.id);
}
this.attachPaymentDrainEnds(bookings);
await this.attachShippingLineCompanies(bookings);
}
/**
* Batched name lookup for shipping-line-owned bookings (`companyId` null,
* `shippingLineCompanyId` set). No relation on the entity — the shipping-line
* module sits above bookings — so a raw query keyed off the loaded ids fills
* `shippingLineCompany` the way `company` is filled for customers.
*/
private async attachShippingLineCompanies(bookings: Booking[]): Promise<void> {
const ids = [
...new Set(
bookings
.map((b) => b.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return;
const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> =
await this.dataSource.query(
`SELECT id, name, email, phone_number AS "phoneNumber"
FROM freight.shipping_line_companies
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const b of bookings) {
const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined;
if (line) {
(b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line;
}
}
}
/**
@@ -1822,6 +1854,7 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
@@ -2048,11 +2081,13 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
const [statusCounts, facets, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getFacets(listFilter),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
@@ -2064,7 +2099,9 @@ export class BookingsService {
return {
metrics,
// Tabs stay unfiltered (whole-set) on purpose — see the DTO comment.
tabs: mapStatusCountsToTabs(statusCounts),
facets,
};
}
@@ -2131,6 +2168,8 @@ export class BookingsService {
{ path: "booking" },
);
await this.attachShippingLineCompanies([booking]);
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {

View File

@@ -67,9 +67,16 @@ export class ContainerValidationService {
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return [];
const units = await this.load20ftUnits(booking);
if (units.length < 2) return [];
return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
}
/**
* Same rule over units that are not (yet) persisted — a completion payload
* being previewed or submitted. Shipping-line completion uses this: its
* cargo only hits the DB after the check passes.
*/
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff);
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FacetBucket } from '../../../common/utils/facets.util';
export class BookingListSummaryMetricsDto {
@ApiProperty({ example: 42 })
@@ -31,4 +32,18 @@ export class BookingListSummaryDto {
@ApiProperty({ type: 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[]>;
}

View File

@@ -111,6 +111,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['SHIPPING_LINE', 'CUSTOMER'],
description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company',
})
@IsOptional()
@IsIn(['SHIPPING_LINE', 'CUSTOMER'])
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@ApiPropertyOptional({
enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings',

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/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 { FileRecord } from '../files/entities/file.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -47,6 +48,8 @@ export interface ContractListFilterOptions {
hasClearanceDocuments?: boolean;
createdFrom?: string;
createdTo?: string;
originYardId?: string;
destinationYardId?: string;
}
@Injectable()
@@ -416,14 +419,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
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(
qb: SelectQueryBuilder<Contract>,
options: ContractListFilterOptions,
omit?: keyof ContractListFilterOptions | 'status',
): void {
if (options.statuses?.length) {
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
} else if (options.status) {
qb.andWhere('contract.status = :status', { status: options.status });
if (omit !== 'status') {
if (options.statuses?.length) {
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
} else if (options.status) {
qb.andWhere('contract.status = :status', { status: options.status });
}
}
if (options.companyId) {
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
@@ -433,7 +444,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
companyProfileId: options.companyProfileId,
});
}
if (options.contractKind) {
if (omit !== 'contractKind' && options.contractKind) {
qb.andWhere('contract.contract_kind = :contractKind', {
contractKind: options.contractKind,
});
@@ -454,20 +465,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
serviceTypeId: options.serviceTypeId,
});
}
if (options.freightType) {
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('contract.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('contract.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
if (omit !== 'tradeDirection' && options.tradeDirections) {
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
qb.andWhere('contract.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
@@ -480,6 +491,47 @@ export class ContractsRepository extends BaseRepository<Contract> {
if (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 ─────────────────────────────────────────────────────────

View File

@@ -768,6 +768,8 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
@@ -789,10 +791,12 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
};
const [statusCounts, metrics] = await Promise.all([
this.contractsRepository.getStatusCounts(),
const [facets, metrics] = await Promise.all([
this.contractsRepository.getFacets(listFilter),
this.contractsRepository.getListSummaryMetrics({
...listFilter,
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. */

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FacetBucket } from '../../../common/utils/facets.util';
export class ContractListSummaryMetricsDto {
@ApiProperty({ example: 42 })
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
@ApiProperty({ type: 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' } })
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[]>;
}

View File

@@ -61,6 +61,22 @@ export class FilterContractDto {
@IsIn([...PAYMENT_CURRENCIES])
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)' })
@IsOptional()
@IsDateString()

View File

@@ -29,52 +29,6 @@ export class DriversRepository extends BaseRepository<Driver> {
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> {
const driver = this.repository.create(driverData);
const result = await this.repository.save(driver);

View File

@@ -1,3 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
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
* 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 = (
opts: {
@@ -19,6 +23,7 @@ const build = (
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
candidate?: { id: string; invoiceNumber: string } | null;
register?: jest.Mock;
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
} = {},
) => {
const register =
@@ -34,12 +39,17 @@ const build = (
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(
{ 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,
{ 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" };
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
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 () => {
let release: () => void = () => {};
const register = jest.fn().mockImplementation(

View File

@@ -1,12 +1,14 @@
import { Injectable, Logger } from "@nestjs/common";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Cron } from "@nestjs/schedule";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
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.
@@ -67,21 +69,62 @@ export class EimsAutoSubmitService {
const candidate = await this.nextCandidate();
if (!candidate) return;
const view = await this.registration.registerInvoiceWithEims(candidate.id);
this.logger.log(
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
);
try {
const view = await this.registration.registerInvoiceWithEims(candidate.id);
this.logger.log(
`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) {
// 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
// next tick at the guard above.
// (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate
// 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}`);
} finally {
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. */
private async systemBlockReason(): Promise<string | null> {
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =

View File

@@ -157,6 +157,12 @@ export interface EimsContextInput {
session: EimsSessionContext;
/** Required when the invoice currency is not ETB. */
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 {
@@ -206,5 +212,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null,
documentType: input.documentType,
reason: input.reason ?? null,
relatedDocument: input.relatedDocument ?? null,
};
}

View File

@@ -295,6 +295,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
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 () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());

View File

@@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import {
EimsDocumentType,
EimsInvoiceRequest,
EimsMapperLine,
toEimsInvoice,
@@ -96,6 +97,27 @@ export class EimsInvoiceRegistrationService {
const invoice = await this.loadInvoiceForMapping(invoiceId);
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
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -114,6 +136,9 @@ export class EimsInvoiceRegistrationService {
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
@@ -639,7 +664,7 @@ export class EimsInvoiceRegistrationService {
): Promise<Invoice & { lines: EimsMapperLine[] }> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId },
relations: { company: true, companyProfile: true },
relations: { company: true, companyProfile: true, relatedInvoice: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);

View File

@@ -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 type { Response } from "express";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { sendPdf } from "../billing/billing.controller";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
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);
}
}

View File

@@ -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,
};
}

View File

@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
@@ -40,10 +41,16 @@ class FakeDb {
}
private manager = {
findOne: async (entity: unknown, options: { where: { id: string } }) =>
entity === Invoice
? (this.invoices.get(options.where.id) ?? null)
: (this.receipts.get(options.where.id) ?? null),
findOne: async (
entity: unknown,
options: { where: { id?: string; invoiceId?: string } },
) => {
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 } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => {
@@ -71,6 +78,7 @@ const build = (
db: FakeDb,
postBearer: jest.Mock,
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
documents: { render: jest.Mock } = { render: jest.fn() },
) =>
new EimsReceiptService(
db.asDataSource(),
@@ -78,6 +86,7 @@ const build = (
{ postBearer } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService,
documents as unknown as InvoiceDocumentService,
);
const okResponse = (over: Record<string, unknown> = {}) => ({
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
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/);
});
});

View File

@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -52,6 +54,7 @@ export class EimsReceiptService {
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService,
private readonly documents: InvoiceDocumentService,
) {}
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 ────────────────────────────────────────────────────────────────────────────────
private async submit(

View File

@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { DocumentsModule } from "../billing/documents/documents.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule,
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],
providers: [

View File

@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
import { NotificationsService } from "../notifications/notifications.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
@@ -146,6 +147,21 @@ export class OtpService {
`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
// 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
@@ -394,7 +410,10 @@ export class OtpService {
// 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.
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;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
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;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {

View File

@@ -31,6 +31,7 @@ import {
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
import { isBypassEnv } from "../../common/dev-bypass.util";
/** 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. */
@@ -256,34 +257,52 @@ export class PaymentService {
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// 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.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// 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",
});
// Dev/staging only: skip the real gateway call entirely and report an
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
// billing notify) runs exactly as it would for a real synchronous
// provider success.
const snapshot: PaymentIntentSnapshot = isBypassEnv()
? {
intentId: `bypass-${input.referenceId}`,
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
merchantOrderId: input.orderRef,
provider: input.method as ProviderMethod,
status: ProviderPaymentStatus.SUCCEEDED,
amountMinor: input.amountMinor,
currency: input.currency,
providerTxnId: `bypass-${input.referenceId}`,
paidAt: new Date().toISOString(),
}
: await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// 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.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// 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 =
snapshot.status === ProviderPaymentStatus.SUCCEEDED;

View File

@@ -53,7 +53,7 @@ export class CreateRateDto {
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])

View File

@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
).toEqual(["PER_TON"]);
});
it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => {
for (const cargoKind of ["CONTAINER", "BULK"] as const) {
expect(
allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }),
).toEqual(["PER_WAGON"]);
}
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CANCELLATION",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_WAGON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);

View File

@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -64,7 +63,9 @@ function unitsForShape(input: {
// wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
// Wagon cancellation fee — scales with the cancelled wagon count, so
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.

View File

@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
'FUEL',
];
/** The yard pair a rate scopes to, already validated against its direction. */
interface YardScope {
@@ -152,7 +165,11 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
): boolean {
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
return (
this.isRouteScoped(appliesTo, trigger) ||
trigger === 'LASHING' ||
trigger === 'CANCELLATION'
);
}
/**
@@ -244,10 +261,13 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
`A ${fee} rate must say whether it covers IMPORT or EXPORT.`,
);
}
// Sold per cargo kind: a container fee names the container type it covers
@@ -255,29 +275,29 @@ export class RatesService {
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers containers or bulk.',
`A ${fee} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
'A container customs clearance rate must name the container type it covers.',
`A container ${fee} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate cannot be scoped to a container type.',
`A bulk ${fee} rate cannot be scoped to a container type.`,
);
}
// The bulk customs fee names the commodity it covers (sugar and
// fertilizer clear differently).
// The bulk fee names the commodity it covers (sugar and fertilizer
// clear — and cancel — differently).
if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.',
`A bulk ${fee} rate must name the bulk cargo type it covers.`,
);
}
if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
`A container ${fee} rate cannot be scoped to a bulk cargo type.`,
);
}
return;
@@ -547,22 +567,21 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
// Exceptions: the directed surcharges (customs clearance, cancellation,
// empty-container return, lashing, fuel) keep direction + cargo scope.
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
: (dto.containerTypeId ?? null);
const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null)
@@ -574,10 +593,7 @@ export class RatesService {
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.)
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
@@ -758,16 +774,15 @@ export class RatesService {
// A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -775,7 +790,7 @@ export class RatesService {
: existing.containerTypeId;
const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType
@@ -784,10 +799,7 @@ export class RatesService {
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection

View File

@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import type { Container20ftUnit } from "../bookings/container-pairing.util";
import { ContainerValidationService } from "../bookings/container-validation.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
private readonly containerValidationService: ContainerValidationService,
) {}
/**
* 20ft weight-pairing check over the completion payload — the same rule the
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
* persisted before the check passes.
*/
private async pairingViolationMessages(
dto: CompleteShippingLineBookingDto,
): Promise<string[]> {
const units: Container20ftUnit[] = [];
for (const line of dto.containers ?? []) {
const containerType = await this.resolveContainerType(line);
if (containerType.sizeFt !== 20) continue;
(line.units ?? []).forEach((u, idx) =>
units.push({
label: u.containerNumber || `20ft-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
}),
);
}
const violations =
await this.containerValidationService.validate20ftPairingUnits(units);
return violations.map((v) => v.message);
}
/** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) {
const shippingLine =
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
);
}
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
// any cargo/credit write below. Same block the contract path applies.
if (booking.freightType === "CONTAINER") {
const pairing = await this.pairingViolationMessages(dto);
if (pairing.length) {
throw new BadRequestException(
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
);
}
}
// Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers,
);
// Pairing is reported, not thrown: the confirm modal shows it next to the
// price (as the customer form does) and disables confirm; /complete
// hard-blocks the same payload.
const pairingErrors =
booking.freightType === "CONTAINER"
? await this.pairingViolationMessages(dto)
: [];
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors,
};
}

View File

@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
* Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent
* single-use tickets; whichever the line opens first works.
* anything else (see `CustomerResetService`). Both carry the SAME single-use
* ticket: minting retires earlier tickets, so two mints would kill the email
* link the moment the SMS went out.
*
* Reports the email send, as that is the one that is always attempted.
*/
async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`;
const channels = [ResetChannel.Email];
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const emailed = await this.customerResetService.sendResetLinkToUser(
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
shippingLine.userId,
ResetChannel.Email,
channels,
{ scope, allowWithoutCredential: true },
);
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) {
this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
);
}
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
}
return emailed;

View File

@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import {
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from "../dto/record-checkpoint.dto";
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
@Post("schedules/:id/dispatch")
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
@ApiOperation({
summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
})
dispatchSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DispatchScheduleDto,
) {
return this.trainSchedulingService.dispatchSchedule(id, dto);
}
@Get("intercity/bookings")
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Patch("schedules/:id/checkpoints/:sequenceNo")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
})
updateCheckpoint(
@Param("id", ParseUUIDPipe) id: string,
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
@Body() dto: UpdateCheckpointDto,
) {
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
}
@Post("schedules/:id/arrive")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -10,8 +10,6 @@ import {
Min,
} from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
kind?: TrainCheckpointKind;
/**
* A checkpoint records where the train is as staff observe it, and the final
* one arrives the schedule — so a backdated value rewrites the journey after
* the fact. Only "now" is accepted; omit the field and the service stamps it.
* When the train was actually at the station — staff often log after the
* fact, so a past value is allowed. The service rejects the future and any
* value out of order with the neighbouring legs.
*/
@ApiProperty({
required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
description:
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
@ApiProperty({ required: false })
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
@MaxLength(500)
note?: string;
}
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
export class UpdateCheckpointDto {
@ApiProperty({
required: false,
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
export class DispatchScheduleDto {
@ApiProperty({
required: false,
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
})
@IsOptional()
@IsISO8601()
actualDepartureAt?: string;
}

View File

@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
update: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
};
trainScheduleBookingsRepository = {
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]),
};
const trainCheckpointEventsRepository = {
trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(),
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
});
});
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {
id: 'sch-track',
status: 'ARRIVED',
routeId: null,
originStationId: 'y0',
destinationStationId: 'y1',
actualDepartureAt: t(8),
};
const events = () => [
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
];
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
});
it('rejects a leg time earlier than the previous leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
).rejects.toThrow(/cannot be earlier than/);
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
});
it('rejects a leg time later than the next leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
).rejects.toThrow(/cannot be later than/);
});
it('rejects a future time', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
).rejects.toThrow(/future/);
});
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
await service.updateCheckpoint('sch-track', 1, {
occurredAt: t(11).toISOString(),
note: 'late log',
});
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
occurredAt: t(11),
note: 'late log',
});
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
actualArrivalAt: t(11),
});
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })

View File

@@ -174,7 +174,11 @@ import {
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
@@ -2160,7 +2164,15 @@ export class TrainSchedulingService {
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
async unassignBooking(
scheduleId: string,
bookingId: string,
userId?: string,
opts: {
/** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */
notifyCustomer?: boolean;
} = {},
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2287,7 +2299,9 @@ export class TrainSchedulingService {
const removedBooking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
if (removedBooking && opts.notifyCustomer !== false) {
this.bookingNotifier.removedFromTrain(removedBooking);
}
this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
);
@@ -2635,7 +2649,7 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
async dispatchSchedule(scheduleId: string) {
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2643,6 +2657,9 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
await this.assertImportDjiboutiMayDepart(schedule);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide.
@@ -2667,7 +2684,6 @@ export class TrainSchedulingService {
}
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {
@@ -4150,6 +4166,7 @@ export class TrainSchedulingService {
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
@@ -4173,8 +4190,14 @@ export class TrainSchedulingService {
});
}
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
// headline timestamp on the same clock the operator just entered.
if (dto.sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
await this.arriveSchedule(scheduleId, occurredAt);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
@@ -4210,11 +4233,133 @@ export class TrainSchedulingService {
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
* position fix, no arrival — those already happened when the leg was logged.
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
* fact as often as during it). The origin/final legs also re-stamp the
* schedule's departure/arrival so the headline figures follow the edit.
*/
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
schedule.status !== TrainScheduleStatusEnum.Arrived
) {
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
}
const stations = await this.buildScheduleStations(schedule);
const station = stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
}
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
// older station numbering.
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const existing =
events.find((e) => e.yardId === station.yardId) ??
events.find((e) => e.sequenceNo === sequenceNo);
if (!existing) {
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
}
const patch: Partial<TrainCheckpointEvent> = {};
if (dto.occurredAt) {
const occurredAt = new Date(dto.occurredAt);
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}
if (patch.occurredAt) {
const finalSeq = stations[stations.length - 1].sequenceNo;
if (sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, {
actualDepartureAt: patch.occurredAt,
});
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
await this.trainSchedulesRepository.update(scheduleId, {
actualArrivalAt: patch.occurredAt,
});
}
}
return this.getScheduleCheckpoints(scheduleId);
}
private assertNotFuture(at: Date, what: string) {
if (Number.isNaN(at.getTime())) {
throw new BadRequestException(`${what} is not a valid date`);
}
// Small skew allowance so an honest "now" from a client clock passes.
if (at.getTime() > Date.now() + 60_000) {
throw new BadRequestException(`${what} cannot be in the future`);
}
}
/**
* A leg's time must not be in the future and must sit in corridor order:
* no earlier than every logged leg before it (and the dispatch time, for
* legs after the origin), no later than every logged leg after it.
* `ignoreEventId` excludes the row being edited from its own bounds.
*/
private async assertCheckpointTime(
schedule: TrainSchedule,
stations: { sequenceNo: number; yardId: string; label: string }[],
sequenceNo: number,
occurredAt: Date,
ignoreEventId?: string,
) {
this.assertNotFuture(occurredAt, 'Checkpoint time');
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
(e) => e.id !== ignoreEventId,
);
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
let floor: { at: Date; label: string } | null = null;
let ceil: { at: Date; label: string } | null = null;
for (const e of events) {
const s = seqOf(e);
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
}
// The origin leg rewrites the departure itself; every later leg must
// follow it.
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
floor = { at: schedule.actualDepartureAt, label: 'departure' };
}
if (floor && occurredAt < floor.at) {
throw new BadRequestException(
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
);
}
if (ceil && occurredAt > ceil.at) {
throw new BadRequestException(
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
);
}
}
/**
* Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -4223,7 +4368,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const now = new Date();
// The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
const now = arrivedAt ?? new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
@@ -4409,6 +4556,7 @@ export class TrainSchedulingService {
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
shippingLineCompany: true,
},
order: { [sortBy]: sortOrder } as never,
skip,
@@ -6107,6 +6255,10 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
// Dedicated shipping-line departure (hidden from customers) — the list
// highlights these rows so staff can tell them apart at a glance.
shippingLineCompanyId: schedule.shippingLineCompanyId ?? null,
shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
@@ -8997,40 +9149,101 @@ export class TrainSchedulingService {
}
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
// Cargo type → allowed wagon types rides along: for bulk, the commodity's
// own wagon-type list (the planner's rule) decides, not only the wagon
// type's generic supportedLoadTypes.
const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({ where: { trainSetWagonId } });
allocRepo.find({
where: { trainSetWagonId },
relations: { booking: { cargoType: { wagonTypes: true } } },
});
const sourceAllocs = await loadAllocations(source.id);
if (!sourceAllocs.length) {
throw new BadRequestException('Source wagon has no load to move');
}
// Target: a slot of this train set, or an empty consist-only wagon of the
// built train (physical wagon with no slot row yet).
// Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
// Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
// "the slot on that wagon" only means the one whose leg overlaps the moving
// load's leg. Null board/alight = the schedule's own endpoints.
const stops = await this.stopYardsForSchedule(schedule);
const spanOf = (slot: {
boardYardId?: string | null;
alightYardId?: string | null;
}): [number, number] => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
const sourceSpan = spanOf(source);
// Target: a slot of this train set, or a physical wagon of this train —
// coupled-but-empty consist wagon (built train), or a wagon already pinned
// by another slot of this set (then: the overlapping-leg slot, or a fresh
// slot for a free leg).
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const wagonForTarget = slotById
? null
: schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
let wagonForTarget: Wagon | null = null;
if (!slotById) {
const wagon = await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId },
relations: { wagonType: true },
});
const onThisTrain =
!!wagon &&
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
slots.some((w) => w.physicalWagonId === wagon.id));
wagonForTarget = onThisTrain ? wagon : null;
}
if (!slotById && !wagonForTarget) {
throw new NotFoundException('Target wagon is not part of this schedule');
}
// A physical wagon holds at most one slot. When the caller addressed the
// wagon directly but a slot is already pinned to it, move into that slot
// rather than minting a second one on the same wagon.
const targetSlot =
slotById ??
(wagonForTarget
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
? (slots.find(
(w) =>
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
) ?? null)
: null);
const consistWagon = targetSlot ? null : wagonForTarget;
// Leg clash guard: after the move, no two slots on one physical wagon may
// ride the same edge. Source load → target wagon; on a swap, target load →
// source wagon.
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
const clashOn = (
physicalWagonId: string | null,
excludeSlotId: string | null,
span: [number, number],
) =>
!!physicalWagonId &&
slots.some(
(w) =>
w.physicalWagonId === physicalWagonId &&
w.id !== excludeSlotId &&
w.id !== source.id &&
(w.allocations?.length ?? 0) > 0 &&
overlaps(spanOf(w), span),
);
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
throw new BadRequestException(
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
);
}
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
if (targetSlot && targetSlot.id === source.id) {
return this.getTrainScheduleById(scheduleId);
}
if (
targetSlot &&
targetAllocs.length &&
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
) {
throw new BadRequestException(
'Swap refused: the source wagon already carries another load on the incoming loads leg.',
);
}
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
@@ -9043,10 +9256,25 @@ export class TrainSchedulingService {
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
// Bulk is allowed on a wagon type when every bulk load's cargo type lists
// it (cargo-type ↔ wagon-type config, same rule the wagon planner uses).
const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => {
const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK');
return (
!!wagonTypeId &&
bulk.length > 0 &&
bulk.every((a) =>
(a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId),
)
);
};
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
wagonType:
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
| null
| undefined,
capacityTons: number,
) => {
const incoming = loadTypesOf(allocs);
@@ -9057,6 +9285,7 @@ export class TrainSchedulingService {
const ok =
supported.includes(loadType) ||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
supported.length === 0;
if (!ok) {
throw new BadRequestException(

View File

@@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
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> {
const vehicle = this.repository.create(vehicleData);
return this.repository.save(vehicle);

View File

@@ -28,6 +28,11 @@ import {
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} 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 {
purpose: VerifaydaPurpose;
@@ -143,6 +148,26 @@ export class VerifaydaService {
async completeVerification(
query: VerifaydaCallbackDto,
): 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) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {

View File

@@ -121,8 +121,46 @@ export class WagonsService {
* (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice.
*/
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
return page;
}
/**
* Latest status-flip dates from the audit log, for the wagons desk columns:
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
* One grouped query per page; null when the log has no such flip.
*/
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
if (!wagons.length) return;
const rows: Array<{
wagonId: string;
lastMaintenanceAt: Date | null;
lastAvailableAt: Date | null;
}> = await this.dataSource
.getRepository(WagonStatusLog)
.createQueryBuilder('l')
.select('l.wagon_id', 'wagonId')
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
'lastMaintenanceAt',
)
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
'lastAvailableAt',
)
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.groupBy('l.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
lastAvailableAt: r?.lastAvailableAt ?? null,
});
}
}
async findById(id: string): Promise<Wagon> {

View File

@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:eims_receipt_register",
"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
// the invoice. Moves money state, so it is its own grant, not part of view.
perm(
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
eimsResolve: "edr_freight_app:invoices:eims_resolve",
eimsCancel: "edr_freight_app:invoices:eims_cancel",
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
memoIssue: "edr_freight_app:invoices:memo_issue",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
},
firstMile: {
@@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
// 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
// filing is not a Finance job function — the endpoints exist for controlled testing and
// exceptional operations, and are assigned to named admins rather than a role preset.
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
// endpoints, and stay off the general Finance role. They are granted to the `chief` position
// instead — see below — the same makerchecker split already used for shipping-line credit
// mark-paid/cancel (Finance raises, chief decides).
FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView,
// 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.invoices.view,
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,
// Decision side of the credit-invoice two-step: finance raises
// mark-paid/cancel requests, the chief approves or rejects them.

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
label={isBulk ? "Commodity" : "Cargo type"}
value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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] };
};
}

View File

@@ -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";
}

View File

@@ -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";

View File

@@ -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);
},
};
}

View 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;
}

View File

@@ -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({});
});
});

View 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>;
}

View File

@@ -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";

View File

@@ -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 };
}

View File

@@ -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 { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
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";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
CANCELLED: "gray",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
CANCELLED: "Cancelled",
};
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&apos;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.
*
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
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(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<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>
)}
</Group>
{status === "CANCELLED" && (
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
{eims.eimsCancellationRemark}
</Alert>
)}
<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>
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
</Card>
);
}

View File

@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
next.containerTypeId = "";
next.cargoTypeId = "";
}
// Cargo kind (customs / lashing) decides both the container-type scope
// Cargo kind (customs / cancellation) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";

View File

@@ -0,0 +1,103 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useEffect, useState } from "react";
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
*/
export function CheckpointTimeModal({
opened,
onClose,
title,
icon,
description,
initialOccurredAt,
initialNote,
submitLabel,
submitColor = "edr-green",
loading,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
title: string;
icon?: React.ReactNode;
description?: string;
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap={8}>
{icon}
<Text fw={700}>{title}</Text>
</Group>
}
>
<Stack gap="md">
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<DateTimePicker
label="Time"
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Textarea
label="Note"
placeholder="Optional"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
maxRows={4}
maxLength={500}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
color={submitColor}
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,423 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
physicalWagonId: string | null;
label: string;
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
* Loads move by click: pick a load, then click a wagon that is free on that
* load's legs (move) or another load (swap). Same API as the consist strip.
*/
export function LegLoadBoardPanel({
schedule,
onChanged,
}: {
schedule: TrainScheduleDetail;
onChanged?: () => void;
}) {
const { toast } = useToast();
const stops: Stop[] = schedule.stops ?? [];
const legs = useMemo(
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
[stops],
);
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
const spanOf = (slot: Slot): Span => {
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
const to = slot.alightYardId
? stops.findIndex((s) => s.yardId === slot.alightYardId)
: stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const rows: WagonRow[] = useMemo(() => {
const byKey = new Map<string, WagonRow>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
let row = byKey.get(key);
if (!row) {
row = {
key,
physicalWagonId: slot.physicalWagonId ?? null,
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
position: slot.position ?? slot.sequenceNo,
typeCode: slot.wagonType?.code ?? null,
capacityTons: slot.capacityTons ?? 0,
slots: [],
};
byKey.set(key, row);
}
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule.trainSet?.wagons, stops]);
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
null,
);
useEffect(() => {
if (!picked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [picked]);
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
const doMove = async (targetWagonId: string, swap: boolean) => {
if (!picked || moveMutation.isPending) return;
try {
await moveMutation.mutateAsync({
scheduleId: schedule.id,
wagonId: picked.slotId,
targetWagonId,
});
toast({ title: swap ? "Loads swapped" : "Load moved" });
setPicked(null);
onChanged?.();
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check wagon type, payload and leg."),
variant: "destructive",
});
}
};
if (stops.length < 2) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
This schedule has no corridor stops yet the leg board needs a route with at least
two stops.
</Alert>
);
}
if (!rows.length) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
No wagons on this train yet.
</Alert>
);
}
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700} size="sm">
Loads per wagon per leg
</Text>
<Text size="xs" c="dimmed">
One row per physical wagon, one column per leg. A wagon reused on different legs
shows one load per leg.{" "}
{canRearrange
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
: "Read-only — the train has departed."}
</Text>
</Stack>
<Group gap="xs">
{sharedRows > 0 ? (
<Badge variant="light" color="violet" radius="sm">
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
</Badge>
) : null}
{picked ? (
<Button
size="xs"
variant="default"
leftSection={<X size={14} />}
onClick={() => setPicked(null)}
>
Cancel move (Esc)
</Button>
) : null}
</Group>
</Group>
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
Wagon
</Table.Th>
{legs.map((leg) => (
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
<Group gap={4} wrap="nowrap">
<Text size="xs" fw={700} truncate>
{leg.from.label}
</Text>
<MoveRight size={12} />
<Text size="xs" fw={700} truncate>
{leg.to.label}
</Text>
</Group>
</Table.Th>
))}
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
const rowFreeForPicked =
!!picked &&
!isPickedRow &&
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
// Where a "move here" lands: an existing empty slot on those legs,
// else the physical wagon itself (the API mints the slot).
const emptyTargetSlot = picked
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
: undefined;
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
// Lay slots into leg columns; uncovered legs render as empty cells.
const cells: React.ReactNode[] = [];
let col = 0;
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
// Empty cell = uncovered leg (target: the physical wagon) or an
// empty slot (target: that slot). Both take the picked load when
// the row is free on its legs.
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
!!picked && overlaps([from, to], picked.span);
return (
<Table.Td
key={`e-${from}`}
colSpan={Math.max(1, to - from)}
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
style={{
cursor: droppable ? "pointer" : "default",
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
outlineOffset: -3,
borderRadius: 6,
}}
>
{droppable ? (
<Text size="xs" c="teal.7" fw={600} ta="center">
Move here
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
</Text>
)}
</Table.Td>
);
};
for (const s of sorted) {
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
if (!s.loaded) {
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
col = Math.max(col, s.span[1]);
continue;
}
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange
? undefined
: s.loaded && !picked
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
: swappable
? () => void doMove(s.slot.id, true)
: isPicked
? () => setPicked(null)
: undefined
}
style={{
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
padding: 4,
}}
>
{s.loaded ? (
<Paper
radius="sm"
px={8}
py={6}
style={{
background: bulk
? "var(--mantine-color-orange-0)"
: "var(--mantine-color-cyan-0)",
borderLeft: `4px solid ${
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
}`,
outline: isPicked
? "2px solid var(--mantine-color-edr-green-6)"
: swappable
? "1px dashed var(--mantine-color-orange-6)"
: undefined,
outlineOffset: 1,
}}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
<Text size="xs" fw={700} truncate>
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{round1(
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
)}{" "}
t
</Text>
</Group>
<Group gap={4} mt={2} wrap="wrap">
{bulk
? allocs.map((a) =>
a.bulkLoad ? (
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
</Badge>
) : null,
)
: containers.map((c) => (
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
{c.containerNumber ?? "no number"}
</Badge>
))}
{swappable ? (
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
swap
</Badge>
) : null}
</Group>
</Paper>
) : (
<Text size="xs" c="dimmed" ta="center">
empty
</Text>
)}
</Table.Td>,
);
col = Math.max(col, s.span[1]);
}
if (col < legs.length) cells.push(emptyCell(col, legs.length));
return (
<Table.Tr
key={row.key}
style={{
background: isPickedRow
? "var(--mantine-color-green-0)"
: rowFreeForPicked
? undefined
: picked
? "var(--mantine-color-gray-0)"
: undefined,
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
}}
>
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
<Group gap={6} wrap="nowrap">
<Badge variant="outline" color="gray" radius="sm" size="sm">
#{row.position}
</Badge>
<Stack gap={0}>
<Text size="sm" fw={700}>
{row.label}
</Text>
<Text size="xs" c="dimmed">
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
</Text>
</Stack>
{row.slots.filter((s) => s.loaded).length > 1 ? (
<Tooltip label="This wagon carries different loads on different legs">
<Badge size="xs" color="violet" variant="light" radius="sm">
shared
</Badge>
</Tooltip>
) : null}
</Group>
</Table.Td>
{cells.map((c, i) => (
<Fragment key={i}>{c}</Fragment>
))}
<Table.Td>
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
{round1(cargoTons)} / {round1(row.capacityTons)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -12,6 +12,7 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
}) {
const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery(
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
const doLogPass = () => {
if (!station) return;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
{
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{
onSuccess: () => {
setJustLogged(true);
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
</>
)}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react";
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
}
const COLUMN_WIDTH = 150;
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
canLog,
loggingSeq,
onLogCheckpoint,
onEditCheckpoint,
}: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
{/* checkpoint time or action */}
{checkpoint ? (
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
<Stack gap={2} align="center">
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius="md"
variant="subtle"
color="gray"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit time
</Button>
) : null}
</Stack>
) : isNext ? (
<Button
size="compact-xs"

View File

@@ -95,7 +95,10 @@ export const QUERY_KEYS = {
byId: (id: string) => ["invoices", "detail", id] as const,
offlineUsd: (filter?: InvoiceListFilter) =>
["invoices", "offline-usd", filter ?? {}] as const,
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
["invoices", "summary", filter ?? {}] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
},
BOOKINGS: {

View File

@@ -134,8 +134,10 @@ export const URL_CONSTANTS = {
BILLING: {
INVOICES: "/billing/invoices",
INVOICES_SUMMARY: "/billing/invoices/summary",
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
OFFLINE_USD: "/billing/offline-usd",
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
},
@@ -146,6 +148,12 @@ export const URL_CONSTANTS = {
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
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: {
@@ -482,6 +490,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,

View File

@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
// Shipping-line bookings have no customer company — the line IS the customer.
customerLabel: booking.shippingLineCompany
? booking.shippingLineCompany.name
: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,

View File

@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
// 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",
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: {
view: "edr_freight_app:first_mile:view",

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal,
Package,
RefreshCw,
Ship,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
ActionIcon,
Badge,
Box,
Button,
Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
};
const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel;
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
// containers → the shipper's own description when given.
const cargoLabel =
booking.freightType === "BULK"
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
: (booking.cargoFreeText?.trim() || null);
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
{shippingLine ? (
<Group gap={6} wrap="nowrap">
<Ship size={14} />
<Text size="sm" fw={600}>
{shippingLine.name}
</Text>
<Badge size="xs" radius="sm" variant="light" color="teal">
Shipping line
</Badge>
</Group>
) : (
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
)}
{cargoLabel ? (
<Text size="sm" c="dimmed">
· {cargoLabel}
</Text>
) : null}
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>

View File

@@ -1,44 +1,33 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Button,
Card,
Checkbox,
Collapse,
Group,
Modal,
MultiSelect,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
FilterX,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
Ship,
User,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { FilterToggle } from "@/components/common/FilterToggle";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -63,7 +52,6 @@ import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
@@ -75,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
@@ -104,61 +98,11 @@ const OWNERSHIP_OPTIONS = [
{ 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() {
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 [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 [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 [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow;
@@ -173,77 +117,6 @@ export default function BookingRequestsPage() {
}, 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).
const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
@@ -257,43 +130,73 @@ export default function BookingRequestsPage() {
[yardRefs],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
// 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. 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: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ 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 =
(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);
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
// Badge on the advanced-filters toggle — active filters hidden behind it.
const advancedFilterCount =
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
const filter: BookingListFilter = useMemo(
() => ({
...(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(() => {
setKindFilter(null);
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setPaidUnallocated(false);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setScheduledFrom(null);
setScheduledTo(null);
resetPage();
}, [resetPage]);
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);
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
@@ -303,8 +206,7 @@ export default function BookingRequestsPage() {
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const hasSearch = query.trim().length > 0;
const hasSearch = controls.searchText.trim().length > 0;
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
@@ -407,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
) : (
<User className="size-3 shrink-0 opacity-70" />
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
</div>
</div>
@@ -585,195 +496,13 @@ export default function BookingRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
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 px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract or customer…"
viewId="booking-requests"
/>
</Box>
{showEmpty ? (
@@ -791,18 +520,7 @@ export default function BookingRequestsPage() {
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>

View File

@@ -3,20 +3,11 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Button,
Card,
Collapse,
Group,
MultiSelect,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
@@ -24,20 +15,18 @@ import {
CheckCircle2,
Clock,
FileText,
FilterX,
Inbox,
LayoutList,
RefreshCw,
Repeat,
Search,
User,
X,
} 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 { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { FilterToggle } from "@/components/common/FilterToggle";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import {
@@ -61,9 +50,9 @@ import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} 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. */
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",
};
/** 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() {
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 [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
// Yard options for the route filter (shared routes reference list, same
// 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(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
// Static shape only (no facet counts) — this is what useFilters needs to
// parse the URL and build API params. Counts are attached separately below,
// 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 [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy,
sortOrder,
const controls = useFilters(filterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
const filter: ContractListFilter = useMemo(
() => ({
...(controls.params as unknown as ContractListFilter),
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
tab: "all",
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(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]);
}),
[controls.params],
);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
@@ -214,13 +195,27 @@ export default function ContractRequestsPage() {
refetch: refetchSummary,
} = 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(
() => (data?.items ?? []).map(toContractListRow),
[data?.items],
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
@@ -450,153 +445,14 @@ export default function ContractRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
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
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 px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={defs}
controls={controls}
searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS}
viewId="contract-requests"
/>
</Box>
{showEmpty ? (
@@ -615,18 +471,7 @@ export default function ContractRequestsPage() {
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
// table-fixed makes the per-column widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"

View File

@@ -5,13 +5,10 @@ import {
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
@@ -22,10 +19,8 @@ import {
Mail,
Phone,
RefreshCw,
Search,
ShieldOff,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -40,12 +35,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -92,28 +83,29 @@ const SORT_OPTIONS = [
{ value: "name:DESC", label: "Name (ZA)" },
] 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() {
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 [sort, setSort] = useState<string>("review:DESC");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -125,7 +117,6 @@ export default function CustomersPage() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Company>[] = useMemo(
() => [
@@ -295,35 +286,24 @@ export default function CustomersPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by company, TIN, email or profile reference…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<FilterBar
defs={NO_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>
<SegmentedControl
size="sm"
radius="md"
value={view}
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);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
@@ -333,21 +313,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<Select
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>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -358,7 +324,7 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
controls.searchText
? "No companies match your search."
: "No companies yet."
}
@@ -370,18 +336,7 @@ export default function CustomersPage() {
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>

View File

@@ -18,11 +18,16 @@ import {
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
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 {
complianceService,
@@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => {
const formatDate = (value?: string | null) =>
value ? new Date(value).toLocaleDateString() : "—";
const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }];
const emptyForm = {
vehicleId: "",
type: "INSPECTION" as ComplianceType,
@@ -91,10 +98,18 @@ export default function CompliancePage() {
},
});
const controls = useListControls(records as ComplianceRecord[], {
searchKeys: ["type", "status", "documentNumber"],
dateKey: "expiryDate",
});
const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 });
const filteredRecords = applyClientFilters(
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({
mutationFn: async (data: typeof formData) => {
@@ -220,17 +235,11 @@ export default function CompliancePage() {
Compliance Records
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={COMPLIANCE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-compliance"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -261,7 +270,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((record) => (
{pagedRecords.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -282,11 +291,8 @@ export default function CompliancePage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredRecords.length)}
/>
</Card>

View File

@@ -1,5 +1,5 @@
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 { 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 { Wagon } from '@/services/wagon.service';
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[];
@@ -86,6 +93,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
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>) =>
@@ -172,9 +181,16 @@ function FleetCrudPage<T extends { id: string }>({
removeSuccessMessage,
hideViewAction = false,
rowActions,
statusOptions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filterDefs: FilterDef[] = useMemo(
() =>
statusOptions
? [{ key: 'status', label: 'Status', type: 'enum', multiple: false, options: statusOptions }]
: [],
[statusOptions],
);
const controls = useFilters(filterDefs, { pageSize: 10 });
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -184,11 +200,13 @@ function FleetCrudPage<T extends { id: string }>({
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const filtered = useMemo(
() =>
applyClientFilters(data ?? [], filterDefs, controls.values, controls.searchText, {
searchValue: searchText,
}),
[data, filterDefs, controls.values, controls.searchText, searchText],
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
@@ -198,12 +216,13 @@ function FleetCrudPage<T extends { id: string }>({
return sortDirection === 'asc' ? result : -result;
});
}, [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 paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -298,18 +317,11 @@ function FleetCrudPage<T extends { id: string }>({
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={`Search ${title.toLowerCase()}`}
/>
<div className="overflow-hidden rounded-lg border bg-card">
<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}
</span>
<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
</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
</Button>
</div>
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
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() {
const query = useQuery(api.trains.list.queryOptions());
return (
@@ -499,6 +552,7 @@ export function TrainMasterDataPage() {
create={useMutation(api.trains.create.mutationOptions())}
update={useMutation(api.trains.update.mutationOptions())}
remove={useMutation(api.trains.remove.mutationOptions())}
statusOptions={TRAIN_STATUS_OPTIONS}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ 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() {
const query = useQuery(api.wagonTypes.list.queryOptions());
const create = useMutation(api.wagonTypes.create.mutationOptions());
const update = useMutation(api.wagonTypes.update.mutationOptions());
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 });
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -546,18 +603,15 @@ export function WagonTypesCrudPage() {
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
);
}, [query.data, search]);
const pageSize = controls.pageSize;
const page = controls.page;
const filtered = useMemo(
() =>
applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, {
searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '),
}),
[query.data, controls.values, controls.searchText],
);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
@@ -573,7 +627,7 @@ export function WagonTypesCrudPage() {
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -689,16 +743,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<FilterBar defs={WAGON_TYPE_FILTER_DEFS} controls={controls} searchPlaceholder="Search wagon types" />
<Paper withBorder radius="md">
<ScrollArea>
@@ -789,7 +834,7 @@ export function WagonTypesCrudPage() {
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
<Pagination total={pageCount} value={page} onChange={controls.setPage} size="sm" />
</Group>
</Stack>
@@ -906,6 +951,7 @@ export function WagonsCrudPage() {
create={useMutation(api.wagons.create.mutationOptions())}
update={useMutation(api.wagons.update.mutationOptions())}
remove={useMutation(api.wagons.remove.mutationOptions())}
statusOptions={WAGON_STATUS_OPTIONS}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
@@ -959,14 +1005,7 @@ export function WagonsCrudPage() {
key: 'status',
label: 'Status',
type: 'select',
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' },
],
options: WAGON_STATUS_OPTIONS,
},
{ key: 'notes', label: 'Notes' },
]}
@@ -999,6 +1038,7 @@ export function ContainersCrudPage() {
create={useMutation(api.containers.create.mutationOptions())}
update={useMutation(api.containers.update.mutationOptions())}
remove={useMutation(api.containers.remove.mutationOptions())}
statusOptions={CONTAINER_STATUS_OPTIONS}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
@@ -1058,6 +1098,7 @@ export function CargoesCrudPage() {
create={useMutation(api.cargoes.create.mutationOptions())}
update={useMutation(api.cargoes.update.mutationOptions())}
remove={useMutation(api.cargoes.remove.mutationOptions())}
statusOptions={CARGO_STATUS_OPTIONS}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
@@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() {
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
statusOptions={LOCOMOTIVE_STATUS_OPTIONS}
searchText={(locomotive) =>
[
locomotive.code,
@@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() {
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },

Some files were not shown because too many files have changed in this diff Show More