feat: add pagination to schedule history and consolidation approvals

- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
Marshal
2026-08-23 04:51:34 +00:00
92 changed files with 3751 additions and 1528 deletions

BIN
EDR-Freight-User-Guide.pdf Normal file

Binary file not shown.

View File

@@ -75,7 +75,7 @@ export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
// Granular train-scheduling actions replace the retired coarse manage:
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
// create a schedule, update (assign/consist/finalize/dispatch/arrive…),
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
export const TrainSchedulingCreate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
@@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () =>
export const TrainSchedulingUpdate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
/**
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
* coarse `update` so it can be granted independently of general schedule
* editing. Same two keys gate import, export, and intercity movements alike:
* the generic per-booking route and the intercity-specific one both use them.
*/
export const TrainSchedulingLoad = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.load);
export const TrainSchedulingUnload = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Optional payment due date finance can set on an additional charge. */
export class AdditionalChargeDueAt3650000000000 implements MigrationInterface {
name = 'AdditionalChargeDueAt3650000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge"
ADD COLUMN IF NOT EXISTS "due_at" timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at"
`);
}
}

View File

@@ -32,6 +32,7 @@ import {
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
@@ -99,6 +100,31 @@ export interface RecordPaymentInput {
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
/**
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
* and `collectedSummary` share it so the summary card can never total a
* different set of invoices than the table below it shows.
*/
export interface InvoiceListFilters {
companyId?: string;
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
eimsStatuses?: string[];
currency?: string;
search?: string;
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
hasBalance?: boolean;
overdue?: boolean;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
}
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
@@ -245,12 +271,7 @@ export class BillingService {
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
filter: InvoiceListFilters,
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
@@ -260,6 +281,57 @@ export class BillingService {
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.statuses?.length) {
qb.andWhere("invoice.status IN (:...statuses)", {
statuses: filter.statuses,
});
}
if (filter.sources?.length) {
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
}
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
eimsStatuses: filter.eimsStatuses,
});
}
if (filter.currency) {
// Stored casing has drifted ("usd" rows exist) — compare normalised.
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency.toUpperCase(),
});
}
if (filter.issuedFrom) {
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
issuedFrom: filter.issuedFrom,
});
}
if (filter.issuedTo) {
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
}
if (filter.dueFrom) {
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
}
if (filter.dueTo) {
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
}
if (filter.minAmount !== undefined) {
qb.andWhere("invoice.totalAmount >= :minAmount", {
minAmount: filter.minAmount,
});
}
if (filter.maxAmount !== undefined) {
qb.andWhere("invoice.totalAmount <= :maxAmount", {
maxAmount: filter.maxAmount,
});
}
if (filter.hasBalance) {
qb.andWhere("invoice.balanceAmount > 0");
}
if (filter.overdue) {
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
// that status, so reading the column alone under-reports the arrears.
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line).
@@ -301,14 +373,11 @@ export class BillingService {
}
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
filter: InvoiceListFilters & {
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
sortBy?: string;
sortOrder?: "ASC" | "DESC";
} = {},
): Promise<{ items: InvoiceListRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
@@ -319,7 +388,14 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw. The id tiebreaker keeps paging stable when the sort column ties
// (issuedAt is null on every DRAFT row).
.orderBy(
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
filter.sortOrder ?? "DESC",
)
.addOrderBy("invoice.id", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize);
@@ -460,12 +536,7 @@ export class BillingService {
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
filter: InvoiceListFilters = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)

View File

@@ -0,0 +1,53 @@
import { plainToInstance } from "class-transformer";
import { validateSync } from "class-validator";
import { FilterInvoiceDto } from "./filter-invoice.dto";
/**
* The list endpoint runs under `forbidNonWhitelisted`, so every param the
* backoffice filter bar sends has to survive transform + validation here or
* the whole request 400s. The CSV filters are the fragile part: they arrive as
* one string and must come out as a validated array.
*/
const parse = (query: Record<string, string>) => {
const dto = plainToInstance(FilterInvoiceDto, query);
return { dto, errors: validateSync(dto).map((e) => e.property) };
};
describe("FilterInvoiceDto", () => {
it("accepts the full filter-bar query and splits the CSV filters", () => {
const { dto, errors } = parse({
page: "2",
pageSize: "10",
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
issuedTo: "2026-08-20T20:59:59.999Z",
dueFrom: "2026-08-01T00:00:00.000Z",
dueTo: "2026-09-01T20:59:59.999Z",
minAmount: "100",
maxAmount: "5000",
hasBalance: "true",
overdue: "false",
sortBy: "balanceAmount",
sortOrder: "asc",
});
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);
expect(dto.overdue).toBe(false);
expect(dto.sortOrder).toBe("ASC");
});
it("rejects a value outside the enum and an unsortable column", () => {
expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]);
expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]);
});
});

View File

@@ -2,14 +2,43 @@ import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
/** Columns the invoice list may be ordered by -> their query-builder expression. */
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
issuedAt: "invoice.issuedAt",
dueAt: "invoice.dueAt",
createdAt: "invoice.createdAt",
totalAmount: "invoice.totalAmount",
balanceAmount: "invoice.balanceAmount",
invoiceNumber: "invoice.invoiceNumber",
};
/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */
const csv = ({ value }: { value: unknown }) =>
typeof value === "string"
? value
.split(",")
.map((v) => v.trim())
.filter(Boolean)
: value;
const bool = ({ value }: { value: unknown }) => value === "true" || value === true;
const num = ({ value }: { value: unknown }) => Number(value);
export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@@ -40,10 +69,97 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */
/**
* Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status`
* when both are sent, so the single-status worklists keep their meaning.
*/
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceStatus), { each: true })
statuses?: Freight.InvoiceStatus[];
/** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
eimsStatuses?: EimsInvoiceStatus[];
/** Manual-payments worklist and the invoice list: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"])
currency?: "USD" | "ETB";
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedFrom?: string;
@ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedTo?: string;
@ApiPropertyOptional({ description: "Due at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
dueFrom?: string;
@ApiPropertyOptional({ description: "Due at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
dueTo?: string;
/** Total amount bounds, in the invoice's own currency — pair with `currency`. */
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
minAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
maxAmount?: number;
@ApiPropertyOptional({ description: "Only invoices with an outstanding balance." })
@IsOptional()
@Transform(bool)
@IsBoolean()
hasBalance?: boolean;
/**
* Outstanding AND past its due date, computed rather than read off `status`:
* nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports.
*/
@ApiPropertyOptional({ description: "Only invoices outstanding past their due date." })
@IsOptional()
@Transform(bool)
@IsBoolean()
overdue?: boolean;
@ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" })
@IsOptional()
@IsIn(Object.keys(INVOICE_SORT_COLUMNS))
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
}

View File

@@ -1,6 +1,7 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -35,6 +36,7 @@ export class AdditionalChargeService {
private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly exchangeService: ExchangeService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService,
@@ -74,6 +76,7 @@ export class AdditionalChargeService {
reason: dto.reason.trim(),
amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(),
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
status: 'DRAFT',
createdByStaffId: staffId,
}),
@@ -132,6 +135,8 @@ export class AdditionalChargeService {
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency,
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
dueAt: charge.dueAt ?? undefined,
lines: [
{
chargeType: 'ADDITIONAL_CHARGE',
@@ -254,9 +259,12 @@ export class AdditionalChargeService {
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
const converted = await Promise.all(rows.map((r) => this.convertAmount(r)));
const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]]));
return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0];
const fx = convertedById.get(r.id) ?? null;
return {
id: r.id,
bookingId: r.bookingId,
@@ -264,6 +272,9 @@ export class AdditionalChargeService {
status: r.status,
amount: Number(r.amount),
currency: r.currency,
convertedAmount: fx?.amount ?? null,
convertedCurrency: fx?.currency ?? null,
dueAt: r.dueAt?.toISOString() ?? null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
@@ -278,4 +289,26 @@ export class AdditionalChargeService {
};
});
}
/**
* Amount converted to the other of ETB/USD, via the existing shared
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
* the rate feed is down — this is a display convenience, not the payable
* amount, so a failure here must never break the charge list.
*/
private async convertAmount(
charge: AdditionalCharge,
): Promise<{ amount: number; currency: string } | null> {
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
try {
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
return { amount: Math.round(amount * 100) / 100, currency: target };
} catch (err) {
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -1,6 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator';
import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
} from 'class-validator';
export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto {
@IsOptional()
@IsIn(['draft', 'send'])
action?: 'draft' | 'send';
/** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */
@ApiPropertyOptional({ example: '2026-09-01' })
@IsOptional()
@IsDateString()
dueDate?: string;
}
export class CancelAdditionalChargeDto {

View File

@@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string;
/** Optional payment due date; unset falls back to the invoice's own default term on send. */
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt?: Date | null;
/** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;

View File

@@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Company } from './entities/company.entity';
import {
companyDraftSql,
companyPendingChangeRequestSql,
} from './company-scope.sql';
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
private static readonly DRAFT_SQL = companyDraftSql('company');
/**
* A company waiting on a reviewer to decide an edit it submitted after being
* approved. These rows are `status = active`, so the pending-application filter
* can never surface them — the review queue needs its own predicate.
*/
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = company.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;
private static readonly PENDING_CHANGE_REQUEST_SQL =
companyPendingChangeRequestSql('company');
/**
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
@@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
type,
kind,
status,
nationality,
createdFrom,
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'review',
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (nationality) {
qb.andWhere('company.nationality = :nationality', { nationality });
}
if (createdFrom) {
qb.andWhere('company.createdAt >= :createdFrom', { createdFrom });
}
if (createdTo) {
qb.andWhere('company.createdAt <= :createdTo', { createdTo });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted

View File

@@ -0,0 +1,40 @@
/**
* Two predicates that define a customer's review state but are NOT columns on
* `companies`. Shared verbatim by the list repository and the export dataset —
* the backoffice offers both as one Status filter, so an export that computed
* "onboarding draft" differently from the list would quietly disagree with the
* screen it was launched from.
*
* Each takes the query's table alias because the two callers use different
* ones (`company` in the repository, `c` in the dataset).
*/
/**
* Still in the portal onboarding wizard: has at least one external profile,
* none of them submitted. Such a row exists from the wizard's first click, so
* it must be excluded from the awaiting-approval queue.
*/
export const companyDraftSql = (alias: string): string => `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
/**
* An already-approved customer who edited their profile: they stay
* `status = active`, so no status filter can ever surface them.
*/
export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = ${alias}.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;

View File

@@ -1,7 +1,20 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Min,
} from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
import {
CompanyKind,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -38,6 +51,21 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({ enum: CompanyNationality })
@IsOptional()
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +

View File

@@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
* Domain semantics that the retired `bookings-list` report used to share.
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
* overrides `total_amount`. Getting either wrong misreports money or weight.

View File

@@ -1,5 +1,9 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Company } from '../../companies/entities/company.entity';
import {
companyDraftSql,
companyPendingChangeRequestSql,
} from '../../companies/company-scope.sql';
import { ExportDataset } from '../export.types';
/**
@@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = {
{ value: 'government', label: 'Government' },
] },
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'nationality', label: 'Nationality', type: 'select', options: [
{ value: 'ethiopian', label: 'Ethiopian' },
{ value: 'foreign', label: 'Foreign' },
] },
// The list's Status filter folds the review queues in, and sends these two
// alongside `status`. They are predicates, not columns — see
// `company-scope.sql.ts`, shared with the list so both agree exactly.
{ key: 'onboardingCompleted', label: 'Onboarding submitted', type: 'select', options: [
{ value: 'true', label: 'Submitted' },
{ value: 'false', label: 'Still a draft' },
] },
{ key: 'hasPendingChangeRequest', label: 'Pending profile changes', type: 'select', options: [
{ value: 'true', label: 'Awaiting review' },
{ value: 'false', label: 'None open' },
] },
{ key: 'search', label: 'Search name, TIN or email', type: 'text' },
],
@@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = {
if (params.type) qb.andWhere('c.type = :type', { type: params.type });
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality });
if (params.onboardingCompleted) {
const draft = companyDraftSql('c');
qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft);
}
if (params.hasPendingChangeRequest) {
const pending = companyPendingChangeRequestSql('c');
qb.andWhere(params.hasPendingChangeRequest === 'true' ? pending : `NOT ${pending}`);
}
if (params.search) {
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
search: `%${params.search as string}%`,

View File

@@ -97,14 +97,21 @@ export const invoicesDataset: ExportDataset = {
filters: [
{ key: 'issued', label: 'Issued', type: 'daterange' },
{ key: 'due', label: 'Due', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' },
// The invoices list page sends a single `status`; accept both so its
// on-screen filter actually carries into the export.
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
] },
{ key: 'minAmount', label: 'Min total', type: 'text' },
{ key: 'maxAmount', label: 'Max total', type: 'text' },
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
{ key: 'overdue', label: 'Overdue only', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
],
@@ -116,10 +123,27 @@ export const invoicesDataset: ExportDataset = {
qb.andWhere('i.deleted_at IS NULL');
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom });
if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo });
const statuses = params.statuses as string[] | null;
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency });
const sources = params.sources as string[] | null;
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
// same as the list endpoint does.
if (params.currency) {
qb.andWhere('UPPER(i.currency) = :currency', {
currency: String(params.currency).toUpperCase(),
});
}
if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) });
if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) });
if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0');
// Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it.
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });

View File

@@ -1,8 +1,13 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from '../bookings/bookings.service';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
@@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service';
@ApiBearerAuth()
@Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones).
@BookingStaff(FREIGHT_PERMS.bookings.operations)
// contracts controller's GL operational endpoints (risk, duty, milestones). No
// class-level guard: the equipment interchange receipt below is customer-reachable,
// every other route here stays staff-only via its own @BookingStaff.
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}
constructor(
private readonly service: ImportOperationsService,
private readonly bookingsService: BookingsService,
) {}
@Get('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId);
}
@Post('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto);
}
@Get('customs/:bookingId')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId);
}
@Post('customs/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -52,6 +65,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/declaration')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -61,6 +75,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/notify-duties-taxes')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -70,6 +85,7 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/duties-taxes-paid')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -79,12 +95,14 @@ export class ImportOperationsController {
}
@Post('customs/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto);
}
@Post('customs/:bookingId/release-permitted')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -94,18 +112,21 @@ export class ImportOperationsController {
}
@Get('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() {
return this.service.listEmptyReturns();
}
@Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
})
@@ -114,6 +135,7 @@ export class ImportOperationsController {
}
@Post('empty-container-returns/:id/status')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string,
@@ -121,4 +143,53 @@ export class ImportOperationsController {
) {
return this.service.updateEmptyReturnStatus(id, dto);
}
@Get('bookings/:bookingId/empty-container-returns')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' })
async listEmptyReturnsForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertCanAccessBooking(user, bookingId);
return this.service.listEmptyReturnsForBooking(bookingId);
}
@Get('empty-container-returns/:id/document')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' })
async equipmentInterchangeDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const row = await this.service.getEmptyReturnOrThrow(id);
// A standalone (no-booking) return has no owner to check against, so it
// stays staff-only.
if (!row.bookingId) {
await this.assertCanAccessBooking(user, null);
} else {
await this.assertCanAccessBooking(user, row.bookingId);
}
const { filename, buffer } = await this.service.equipmentInterchangeDocument(row);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
/**
* Staff pass on permission alone. A customer must own the booking; `null`
* (a standalone, booking-less return) has no owner for a customer to match,
* so it 404s them the same way a foreign booking would.
*/
private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise<void> {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return;
if (!bookingId) {
throw new NotFoundException('Not found');
}
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
}

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
@@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service';
ImportCustomsFinalization,
EmptyContainerReturn,
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route.
WarehousesModule,
BookingsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

@@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
@@ -39,6 +42,8 @@ export class ImportOperationsService {
private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
) {}
listIncidents(bookingId?: string) {
@@ -150,6 +155,10 @@ export class ImportOperationsService {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
}
listEmptyReturnsForBooking(bookingId: string) {
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
@@ -248,6 +257,142 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } });
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
return row;
}
/**
* Equipment Interchange Receipt — container number/size, exact return
* timestamp, depot, condition, and the carrier/booking reference that ties
* the box back to its bill of lading. Handed to the customer to download.
*/
async equipmentInterchangeDocument(
row: EmptyContainerReturn,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = row.bookingId
? ((
await this.emptyReturns.manager.query(
`SELECT b.reference, c.name AS company_name
FROM freight.bookings b
LEFT JOIN freight.companies c ON c.id = b.company_id
WHERE b.id = $1`,
[row.bookingId],
)
)[0] as { reference: string; company_name: string | null } | undefined)
: undefined;
const html = this.buildEquipmentInterchangeHtml(row, booking, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt');
return {
filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`,
buffer,
};
}
private buildEquipmentInterchangeHtml(
row: EmptyContainerReturn,
booking: { reference: string; company_name: string | null } | undefined,
opts: { logoImageUrl?: string | null },
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const dateTime = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
const carrier =
row.returnedBy === 'EDR'
? 'EDR Last Mile'
: row.returnedBy === 'CUSTOMER'
? 'Customer Self-Haul'
: '-';
const rows: Array<[string, string]> = [
['Container Number', row.containerNumber],
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
['Date & Time of Return', dateTime(row.returnDate)],
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
['Condition Status', row.condition || 'Good — no exceptions noted'],
['Carrier', carrier],
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
['Shipping Line / Customer', booking?.company_name || '-'],
['Current Status', row.status.replace(/_/g, ' ')],
['Handover Note', row.handoverNote || '-'],
];
const rowsHtml = rows
.map(
([label, value]) =>
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Equipment Interchange Receipt</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
.meta { text-align: right; font-size: 11px; color: #475569; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(opts.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Equipment Interchange Receipt</h1>
</div>
<div class="meta">
Receipt No.
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<table>
<tbody>
${rowsHtml}
</tbody>
</table>
<div class="notice">
This receipt confirms the physical interchange of the equipment described above at the
depot/location and time stated. Both parties should verify the container number, size,
and condition recorded here before signing.
</div>
<div class="signatures">
<div class="line">Depot officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</body>
</html>`;
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;

View File

@@ -58,13 +58,18 @@ export class CreateOperationsTargetDto {
@ApiPropertyOptional({
description:
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
'Station targets only: which cargo category this station plan covers. Ignored for the ' +
'other dimensions, whose key already carries the category.',
example: 'CONTAINER_IMPORT_MULTIMODAL',
})
@IsOptional()
// `'' ?? null` is `''`, and an empty string matches neither the unique
// index's `COALESCE(cargo_category, '')` nor the report's join — it reads as
// a category that does not exist. Blank means absent.
@Transform(({ value }) => (value === '' ? null : value))
@IsString()
@MaxLength(60)
cargoCategory?: string;
cargoCategory?: string | null;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,8 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
/**
* Planning buckets the reports offer. Mirrors the reports' period filter
* (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be
* able to commit a number at whatever grain the business quotes it, and the
* report then re-gathers it into whatever grain the viewer asks for.
*
* All eight anchor to the calendar year. `nine_month` and `ninety_day` are the
* two that do not divide it evenly: their last block of a year is short (OctDec
* and the 56 days after day 360). That is inherent to the unit, not a bug.
*/
export const TARGET_PERIOD_TYPES = [
'day',
'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
] as const;
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
/** What is being planned. */
@@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
half_year: 'Half-yearly',
nine_month: 'Nine-monthly',
ninety_day: '90-day',
year: 'Yearly',
};

View File

@@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OperationsStandard } from './entities/operations-standard.entity';
@@ -13,10 +13,11 @@ import { OperationsTargetsService } from './operations-targets.service';
* standards (one settings row) and the planned targets the reports compare
* actuals against.
*
* Global because the reports module reads the standards row on every run and
* has no other reason to import this.
* Not global, and deliberately so: nothing outside this module injects either
* service. The reports read both tables in raw SQL — `STANDARDS_JOIN` and
* `plannedRowsSql` in `reports/operations-classification.ts` — so the exports
* below are for future callers, not current ones.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
controllers: [OperationsStandardsController, OperationsTargetsController],

View File

@@ -0,0 +1,144 @@
import {
TARGET_PERIOD_LABELS,
TARGET_PERIOD_TYPES,
TargetPeriodType,
} from './entities/operations-target.entity';
import { normalisePeriodStart } from './operations-targets.service';
/**
* `normalisePeriodStart` decides which slot a target occupies — the unique
* index is keyed on its output — and it is one half of a pair. The other half
* is `PERIOD_UNITS[...].truncOn` in `reports/revenue-classification.ts`, which
* buckets the actuals. A target that snaps to a boundary the report does not
* bucket on is a plan measured against a period that does not exist, and
* nothing downstream would say so.
*
* Everything here is UTC on purpose: the column is a bare `date`, and the same
* arithmetic in local time shifts a 1st-of-month target into the previous month
* for anyone east of Greenwich.
*/
describe('normalisePeriodStart', () => {
it('leaves a daily target on its own day', () => {
expect(normalisePeriodStart('day', '2026-08-21')).toBe('2026-08-21');
});
it('snaps a week to its Monday', () => {
// 2026-08-21 is a Friday.
expect(normalisePeriodStart('week', '2026-08-21')).toBe('2026-08-17');
// A Sunday belongs to the week that started six days earlier, not the next.
expect(normalisePeriodStart('week', '2026-08-23')).toBe('2026-08-17');
expect(normalisePeriodStart('week', '2026-08-17')).toBe('2026-08-17');
});
it('snaps a month to the 1st', () => {
expect(normalisePeriodStart('month', '2026-08-21')).toBe('2026-08-01');
expect(normalisePeriodStart('month', '2026-08-01')).toBe('2026-08-01');
});
it('snaps a quarter to Jan/Apr/Jul/Oct', () => {
expect(normalisePeriodStart('quarter', '2026-02-14')).toBe('2026-01-01');
expect(normalisePeriodStart('quarter', '2026-05-01')).toBe('2026-04-01');
expect(normalisePeriodStart('quarter', '2026-08-21')).toBe('2026-07-01');
expect(normalisePeriodStart('quarter', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a half-year to Jan/Jul', () => {
expect(normalisePeriodStart('half_year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-06-30')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-07-01')).toBe('2026-07-01');
expect(normalisePeriodStart('half_year', '2026-12-31')).toBe('2026-07-01');
});
it('snaps a nine-month to Jan/Oct, leaving a short final block', () => {
expect(normalisePeriodStart('nine_month', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('nine_month', '2026-09-30')).toBe('2026-01-01');
// OctDec is three months, not nine. The block is short by design: nine
// does not divide twelve, and drifting out of the calendar year is worse.
expect(normalisePeriodStart('nine_month', '2026-10-01')).toBe('2026-10-01');
expect(normalisePeriodStart('nine_month', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a 90-day block to day 1/91/181/271 of its year', () => {
expect(normalisePeriodStart('ninety_day', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('ninety_day', '2026-03-31')).toBe('2026-01-01'); // day 90
expect(normalisePeriodStart('ninety_day', '2026-04-01')).toBe('2026-04-01'); // day 91
expect(normalisePeriodStart('ninety_day', '2026-06-29')).toBe('2026-04-01'); // day 180
expect(normalisePeriodStart('ninety_day', '2026-06-30')).toBe('2026-06-30'); // day 181
expect(normalisePeriodStart('ninety_day', '2026-07-01')).toBe('2026-06-30');
expect(normalisePeriodStart('ninety_day', '2026-09-27')).toBe('2026-06-30'); // day 270
expect(normalisePeriodStart('ninety_day', '2026-09-28')).toBe('2026-09-28'); // day 271
});
it('widens the fourth 90-day block instead of opening a stub fifth', () => {
// Day 361 onwards would be its own block under an uncapped floor division —
// a five-day bucket at the end of every year. The cap keeps it in block 4,
// which must therefore match what late September resolves to.
const blockFour = normalisePeriodStart('ninety_day', '2026-09-28');
expect(normalisePeriodStart('ninety_day', '2026-12-27')).toBe(blockFour);
expect(normalisePeriodStart('ninety_day', '2026-12-31')).toBe(blockFour);
});
it('handles a leap year, where day 366 still lands in the fourth block', () => {
// 2028 is a leap year: Dec 31 is day 366.
expect(normalisePeriodStart('ninety_day', '2028-12-31')).toBe(
normalisePeriodStart('ninety_day', '2028-09-27'),
);
});
it('snaps a year to Jan 1', () => {
expect(normalisePeriodStart('year', '2026-08-21')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-12-31')).toBe('2026-01-01');
});
it('ignores any time component rather than letting it shift the day', () => {
expect(normalisePeriodStart('day', '2026-08-21T23:59:59.999Z')).toBe('2026-08-21');
expect(normalisePeriodStart('month', '2026-08-01T22:00:00+03:00')).toBe('2026-08-01');
});
it('is idempotent for every period type', () => {
// A normalised start must survive a second pass untouched, because `update`
// re-normalises whatever is already stored.
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-01-01', '2026-05-17', '2026-08-21', '2026-12-31']) {
const once = normalisePeriodStart(periodType, date);
expect(normalisePeriodStart(periodType, once)).toBe(once);
}
}
});
it('never moves a date forward, only back to its block start', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-02-28', '2026-06-15', '2026-10-02', '2026-12-31']) {
expect(normalisePeriodStart(periodType, date) <= date).toBe(true);
}
}
});
});
describe('target period vocabulary', () => {
it('labels every period type, so the admin grid shows no raw key', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
expect(TARGET_PERIOD_LABELS[periodType]).toBeTruthy();
}
expect(Object.keys(TARGET_PERIOD_LABELS).sort()).toEqual([...TARGET_PERIOD_TYPES].sort());
});
it('keeps every period type inside the column width', () => {
// `period_type` is varchar(10); `nine_month` and `ninety_day` are exactly 10.
for (const periodType of TARGET_PERIOD_TYPES) {
expect(periodType.length).toBeLessThanOrEqual(10);
}
});
it('has a normalisation branch for every declared period type', () => {
// A type added to the union without a `case` would silently fall through
// and store an un-snapped date. Every type must move Dec 31 to a block
// start except `day`, which legitimately keeps it.
const unhandled = TARGET_PERIOD_TYPES.filter(
(t: TargetPeriodType) =>
t !== 'day' && normalisePeriodStart(t, '2026-12-31') === '2026-12-31',
);
expect(unhandled).toEqual([]);
});
});

View File

@@ -1,5 +1,10 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm';
@@ -12,6 +17,8 @@ import {
TARGET_DIMENSION_LABELS,
TARGET_METRIC_LABELS,
TARGET_PERIOD_LABELS,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from './entities/operations-target.entity';
import {
@@ -19,10 +26,19 @@ import {
CONTAINER_CLASSES,
} from '../reports/operations-classification';
const MS_PER_DAY = 86_400_000;
/**
* Normalises any date inside a bucket to the bucket's first day, matching
* Postgres `date_trunc` — which is what the reports group by. Week starts
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
* Normalises any date inside a bucket to the bucket's first day, matching the
* bucket expression the reports group by (`PERIOD_UNITS` in
* `reports/revenue-classification.ts`). Week starts Monday, the same as
* `date_trunc('week', …)` and ISO week numbering.
*
* The four units Postgres has no `date_trunc` for are anchored to the calendar
* year, exactly as their SQL twins are: half-years at Jan/Jul, nine-months at
* Jan/Oct, ninety-days at day 1/91/181/271. **This function and
* `PERIOD_UNITS[...].truncOn` must agree** — a target whose `period_start` is
* not a real block start plans against a bucket boundary that does not exist.
*
* Done in UTC throughout: the stored column is a bare `date`, and running the
* arithmetic in local time would shift a 1st-of-month target into the previous
@@ -31,6 +47,8 @@ import {
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) {
case 'day':
break;
case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7;
@@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string
case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
break;
case 'half_year':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1);
break;
case 'nine_month':
// Two blocks a year, not 1.33: JanSep, then a short OctDec.
d.setUTCMonth(Math.floor(d.getUTCMonth() / 9) * 9, 1);
break;
case 'ninety_day': {
// Day-of-year, zero-based, so this matches SQL's 1-based `(doy - 1) / 90`.
// Capped at block 3 for the same reason the SQL caps it: uncapped, the
// last days of December become a 5-day stub block of their own.
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
const dayIndex = Math.floor((d.getTime() - yearStart) / MS_PER_DAY);
d.setTime(yearStart + Math.min(Math.floor(dayIndex / 90), 3) * 90 * MS_PER_DAY);
break;
}
case 'year':
d.setUTCMonth(0, 1);
break;
@@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category;
/**
* The keys a target may be stored against, per dimension. A report matches a
* target by this exact string, so a key outside the set here is a plan no
* report can ever find — and nothing downstream would ever say so. `station` is
* absent on purpose: yard codes are admin-managed rows, resolved live.
*
* `UNCLASSIFIED` is accepted for `cargo_category` even though the admin form
* does not offer it, because `CARGO_CATEGORY_EXPR` does emit it — rejecting a
* key the reports can match would be stricter than the reports themselves.
*/
const KEYS_BY_DIMENSION: Record<Exclude<TargetDimension, 'station'>, Set<string>> = {
cargo_category: new Set(CARGO_CATEGORIES.map((o) => o.value)),
container_class: new Set(CONTAINER_CLASSES.map((o) => o.value)),
};
/** The columns that decide which report row a target lines up with. */
type TargetSlot = Pick<
OperationsTarget,
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
>;
@Injectable()
export class OperationsTargetsService {
constructor(
@@ -152,35 +207,113 @@ export class OperationsTargetsService {
}
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
const cargoCategory = dto.cargoCategory ?? null;
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
const slot = await this.resolveSlot(dto);
await this.assertSlotFree(slot);
return this.repository.save(this.repository.create({ ...dto, ...slot }));
}
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType;
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
const next = {
periodType,
periodStart,
const slot = await this.resolveSlot({
periodType: dto.periodType ?? current.periodType,
periodStart: dto.periodStart ?? current.periodStart,
metric: dto.metric ?? current.metric,
dimension: dto.dimension ?? current.dimension,
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
// An absent key means "unchanged" only while the dimension still wants a
// category at all — `resolveSlot` drops it when the dimension no longer
// does, which is the whole point of routing both paths through it.
cargoCategory:
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
};
await this.assertSlotFree(next, id);
dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory,
});
await this.assertSlotFree(slot, id);
await this.repository.update(id, {
...next,
...slot,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}),
});
return this.findById(id);
}
/**
* Everything that decides which report row a target lines up with, resolved
* in one place so `create` and `update` cannot drift apart.
*
* `cargoCategory` is **derived from the dimension, never carried over**. A
* station's plan is per station AND per cargo type; the other two dimensions
* already carry the category in `dimensionKey`. A stale category left on a
* row whose dimension has moved on is not cosmetic — it survives the
* `COALESCE(cargo_category, '')` unique index alongside the legitimate
* null-category row, `plannedRowsSql` groups by it, and the two plan rows
* then both join the same operated row: the category lists twice, each time
* carrying the full operated tonnage, while the summary tiles stay correct.
*/
private async resolveSlot(input: {
periodType: TargetPeriodType;
periodStart: string;
metric: TargetMetric;
dimension: TargetDimension;
dimensionKey: string;
cargoCategory?: string | null;
}): Promise<TargetSlot> {
const periodStart = normalisePeriodStart(input.periodType, input.periodStart);
await this.assertDimensionKey(input.dimension, input.dimensionKey);
const base = {
periodType: input.periodType,
periodStart,
metric: input.metric,
dimension: input.dimension,
dimensionKey: input.dimensionKey,
};
if (input.dimension !== 'station') {
return { ...base, cargoCategory: null };
}
const cargoCategory = input.cargoCategory || null;
if (!cargoCategory) {
throw new BadRequestException(
'A station target needs a cargo category — the plan is per station and per cargo type. ' +
'Without one the report has nothing to match it against.',
);
}
if (!KEYS_BY_DIMENSION.cargo_category.has(cargoCategory)) {
throw new BadRequestException(
`"${cargoCategory}" is not a cargo category the reports produce. ` +
`Expected one of: ${[...KEYS_BY_DIMENSION.cargo_category].join(', ')}`,
);
}
return { ...base, cargoCategory };
}
/**
* A `dimensionKey` the reports never emit is a plan that silently never
* joins — the row lists fine and its label falls back to the raw key, so
* nothing downstream ever reports the mistake. Cheaper to reject on write.
*/
private async assertDimensionKey(dimension: TargetDimension, key: string): Promise<void> {
if (dimension === 'station') {
const yards = await this.yardLabels();
if (!yards.has(key)) {
throw new BadRequestException(
`"${key}" is not a known station code. A station target is keyed on ` +
'`yards.code`, which is what the reports match against.',
);
}
return;
}
const allowed = KEYS_BY_DIMENSION[dimension];
if (!allowed.has(key)) {
throw new BadRequestException(
`"${key}" is not a ${TARGET_DIMENSION_LABELS[dimension].toLowerCase()} the reports ` +
`produce. Expected one of: ${[...allowed].join(', ')}`,
);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import type { OverviewLayoutKey } from '../../../seed/freight-permissions.registry';
/**
* One entry per `GET /overview/layouts` item: a layout the caller holds the
* `edr_freight_app:overview:<key>:view` permission for. Mirrors the reports
* module's catalog entry (`ReportCatalogEntry`) — same "server filters by
* permission, frontend just renders what comes back" shape.
*/
export class OverviewLayoutDto {
@ApiProperty({
enum: ['clearance', 'occ', 'operation', 'marketer', 'finance', 'executive'],
})
key!: OverviewLayoutKey;
@ApiProperty()
label!: string;
}

View File

@@ -9,7 +9,13 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
OVERVIEW_LAYOUT_KEYS,
OVERVIEW_LAYOUT_LABELS,
} from '../../seed/freight-permissions.registry';
import { OverviewLayoutDto } from './dto/overview-layout.dto';
import { OverviewQueryDto } from './dto/overview-query.dto';
import { OverviewResponseDto } from './dto/overview-response.dto';
import {
@@ -34,6 +40,22 @@ export class OverviewController {
private readonly userTradeAccessService: UserTradeAccessService,
) {}
/**
* Layouts the caller has permission to render, in priority order — exactly
* the same "server filters by permission, frontend just renders what comes
* back" shape as GET /reports. A caller lands on exactly one layout, so the
* frontend picks the first entry here rather than rendering the whole list.
*/
@Get('layouts')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Overview dashboard layouts the caller has permission to render' })
@ApiOkResponse({ type: OverviewLayoutDto, isArray: true })
getLayouts(@CurrentUser() user: TCurrentUser): OverviewLayoutDto[] {
return OVERVIEW_LAYOUT_KEYS.filter((key) =>
hasFreightPermission(user, FREIGHT_PERMS.overview.layout(key)),
).map((key) => ({ key, label: OVERVIEW_LAYOUT_LABELS[key] }));
}
@Get()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })

View File

@@ -1,129 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
// (same guard as the retired report-queries.ts).
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking.
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function applyFilters(
ctx: ReportContext,
qb: SelectQueryBuilder<ObjectLiteral>,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (params.search) {
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
search: `%${params.search}%`,
});
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const bookingsListReport: ReportDefinition = {
key: 'bookings-list',
title: 'Bookings',
description: 'Every booking with customer, route, cargo and revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'created', dir: 'DESC' },
query(ctx) {
const qb = ctx.ds
.createQueryBuilder()
.select('b.reference', 'reference')
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
.addSelect('c.name', 'customer')
.addSelect('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect('o.label', 'origin')
.addSelect('d.label', 'destination')
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
.addSelect(`ROUND(${TONS})::float8`, 'tons')
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
return applyFilters(ctx, qb);
},
async summary(ctx) {
const qb = applyFilters(
ctx,
ctx.ds
.createQueryBuilder()
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id'),
);
const row = await qb.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' },
@@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addGroupBy(originationExpr(params, 'code'))
.addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade, keyed the way a station plan is: per station
// AND per cargo type. Unfiltered by date, so a mid-year view still knows
// what the station has already hauled against its target.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, params), 'bucket')
.addSelect(stationCode, 'act_key')
.addSelect(CARGO_CATEGORY_EXPR, 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, params))
.addGroupBy(stationCode)
.addGroupBy(CARGO_CATEGORY_EXPR);
// A station plan is keyed on station AND cargo type, so the join needs
// both. Full outer, so a station-and-cargo line that was planned and never
// ran still reports its miss — the OCC report is full of those.
@@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan
p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p
FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'station',
params,
attained.getQuery(),
)}) p
ON p.period = o.period
AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`;
@@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = {
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(params),
})
.select('r.period', 'period')
.addSelect('r.station', 'station')
.addSelect('r.origination', 'origination')
@@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons')

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
@@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
.groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR);
// What the cascade measures attainment from: the same tonnage, over the
// target's whole period rather than the user's date window. Bucketed on the
// block start, not the label, so it joins the plan on a real timestamp.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// Full outer join so a planned cargo category that moved nothing still
// reports its miss instead of disappearing from the table.
const combined = `
@@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan
p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p
FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons')
.addSelect('r.teu::int', 'teu')

View File

@@ -1,83 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.where('ct.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo });
if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind });
if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const contractLifecycleReport: ReportDefinition = {
key: 'contract-lifecycle',
title: 'Contracts',
description: 'Signed, active and cancelled contracts',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Valid from', type: 'daterange' },
{ key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'freightType', label: 'Freight type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' },
{ key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'signedAt', label: 'Signed', type: 'date' },
],
defaultSort: { key: 'validFrom', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer')
.addSelect('ct.contract_kind', 'kind')
.addSelect('ct.trade_direction', 'direction')
.addSelect('ct.freight_type', 'freightType')
.addSelect('ct.status', 'status')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed')
.addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.total ?? 0) },
{ label: 'Signed', value: Number(row?.signed ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -1,67 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
// CompanyProfile fields, not Company's — a company can hold several profiles
// (e.g. importer AND exporter), each independently approved/suspended.
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(CompanyProfile, 'cp')
.innerJoin(Company, 'c', 'c.id = cp.company_id')
.where('cp.deleted_at IS NULL');
if (params.type) qb.andWhere('cp.type = :type', { type: params.type });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses });
return qb;
}
export const customerStatusReport: ReportDefinition = {
key: 'customer-status',
title: 'Customer Profiles',
description: 'Company profiles by role type and approval status',
group: 'Commercial',
filters: [
{ key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'note', label: 'Note', type: 'string' },
{ key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' },
],
defaultSort: { key: 'reviewedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'company')
.addSelect('cp.type', 'type')
.addSelect('cp.status', 'status')
.addSelect("COALESCE(cp.reference, '')", 'reference')
.addSelect("COALESCE(cp.review_note, '')", 'note')
.addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended')
.setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended })
.getRawOne();
return [
{ label: 'Profiles', value: Number(row?.total ?? 0) },
{ label: 'Active', value: Number(row?.active ?? 0) },
{ label: 'Suspended', value: Number(row?.suspended ?? 0) },
];
},
};

View File

@@ -1,72 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id')
.where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicesByStatusReport: ReportDefinition = {
key: 'invoices-by-status',
title: 'Invoices',
description: 'Every invoice with customer, profile type and settlement status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Issued', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'profileType', label: 'Profile', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'paidAmount', label: 'Paid', type: 'money' },
{ key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' },
{ key: 'dueAt', label: 'Due', type: 'date' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.invoice_number', 'invoiceNumber')
.addSelect('c.name', 'customer')
.addSelect("COALESCE(cp.type, 'Unknown')", 'profileType')
.addSelect('i.status', 'status')
.addSelect('ROUND(i.total_amount)::float8', 'totalAmount')
.addSelect('ROUND(i.paid_amount)::float8', 'paidAmount')
.addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount')
.addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,73 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// No direct company link on payments (refId points at whatever the intent was
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
const STATUS_OPTIONS = [
{ value: 'action-required', label: 'Action required' },
{ value: 'processing', label: 'Processing' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
{ value: 'refunded', label: 'Refunded' },
];
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
(v) => ({ value: v, label: v }),
);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
// payments carries no deleted_at column (unlike the rest of the schema) —
// confirmed against the live DB, not assumed from BaseEntity.
const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1');
if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo });
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses });
return qb;
}
export const paymentsByStatusReport: ReportDefinition = {
key: 'payments-by-status',
title: 'Payments by Status',
description: 'Payment volume and value by status, method and currency',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'method', label: 'Method', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'payments', label: 'Payments', type: 'number', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'amount', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('p.status', 'status')
.addSelect('p.method', 'method')
.addSelect('p.currency', 'currency')
.addSelect('COUNT(*)::int', 'payments')
.addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount')
.groupBy('p.status')
.addGroupBy('p.method')
.addGroupBy('p.currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'payments')
.addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid')
.getRawOne();
return [
{ label: 'Payments', value: Number(row?.payments ?? 0) },
{ label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,71 @@
import { WAGON_CANCELLATION_STATUSES } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { ShippingLineCreditStatus } from '../../shipping-lines/entities/shipping-line-credit.entity';
import {
CREDIT_LIABILITY_STATUS,
INVOICE_SIDE_EXPR,
LEDGER_SIDES,
UNINVOICED_CREDIT_STATUS,
receivablesPayablesReport,
} from './receivables-payables.report';
/**
* The report's whole point is the sign of the money: a cancellation FEE is
* owed TO EDR, and the cancelled freight is owed BACK to the customer as
* bookable credit. These tests pin the two down at the string level — the SQL
* itself is validated against the database, not here.
*/
describe('receivables-payables report', () => {
it('treats exactly one wagon-cancellation status as a liability', () => {
expect(WAGON_CANCELLATION_STATUSES).toContain(CREDIT_LIABILITY_STATUS);
// Every other status owes nothing: nothing cut yet (FEE_PENDING), redeemed
// (REBOOKED), or voided (WITHDRAWN / EXPIRED). If a new status appears,
// this fails until someone decides which side of the ledger it lands on.
expect(WAGON_CANCELLATION_STATUSES.filter((s) => s !== CREDIT_LIABILITY_STATUS).sort()).toEqual(
['EXPIRED', 'FEE_PENDING', 'REBOOKED', 'WITHDRAWN'],
);
});
it('counts only the shipping-line credit status that has no invoice behind it', () => {
expect(UNINVOICED_CREDIT_STATUS).toBe(ShippingLineCreditStatus.Unbilled);
// BILLED is debt too, but it is counted through its invoice on the invoice
// branch — taking it here as well would double it.
expect(UNINVOICED_CREDIT_STATUS).not.toBe(ShippingLineCreditStatus.Billed);
});
it('never classifies the cancellation fee as a payable', () => {
// The fee invoice rides the booking's invoice list; while it is open it is
// an ordinary receivable balance, and it must not reach a PAYABLE arm.
expect(INVOICE_SIDE_EXPR).not.toContain('WAGON_CANCEL_FEE');
expect(INVOICE_SIDE_EXPR).not.toContain('CANCELLATION_FEE');
});
it('does not double-count a booking already carried by the cancellation ledger', () => {
expect(INVOICE_SIDE_EXPR).toContain('NOT EXISTS');
expect(INVOICE_SIDE_EXPR).toContain('booking_wagon_cancellations');
});
it('emits exactly the side keys the filter offers', () => {
const declared = LEDGER_SIDES.map((s) => s.value).sort();
expect(declared).toEqual([
'PAYABLE_PREPAID',
'PAYABLE_WAGON_CREDIT',
'RECEIVABLE_OPEN',
'RECEIVABLE_SL_INVOICED',
'RECEIVABLE_SL_UNBILLED',
]);
// The summary KPIs split on these prefixes; a key matching neither would
// silently vanish from both totals.
for (const key of declared) {
expect(key.startsWith('RECEIVABLE') || key.startsWith('PAYABLE')).toBe(true);
}
});
it('sorts on the union wrapper, never on a branch-local alias', () => {
// The runner appends ORDER BY outside the union subquery, where `i.*`,
// `b.*` and `bwc.*` do not exist.
for (const col of receivablesPayablesReport.columns) {
if (!col.sortExpr) continue;
expect(col.sortExpr).toMatch(/^r\./);
}
});
});

View File

@@ -1,5 +1,12 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { Company } from '../../companies/entities/company.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity';
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
import {
PAYER_EXPR,
@@ -10,47 +17,287 @@ import {
} from '../revenue-classification';
export const LEDGER_SIDES: ReportFilterOption[] = [
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' },
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' },
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' },
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' },
{ value: 'SETTLED', label: 'Settled' },
{
value: 'RECEIVABLE_SL_UNBILLED',
label: 'Receivable — shipping-line service, not yet invoiced',
},
{
value: 'RECEIVABLE_SL_INVOICED',
label: 'Receivable — shipping-line invoice open',
},
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' },
{
value: 'PAYABLE_WAGON_CREDIT',
label: 'Payable — unapplied wagon-cancellation credit',
},
{ value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' },
];
/**
* Which side of the ledger an invoice sits on.
* Which side of the ledger a row sits on, and why the report is a union of
* three fact tables rather than a CASE over `invoices`.
*
* Receivable = EDR delivered and is owed money — the shipping-line credit
* arrangement, plus any invoice still carrying a balance.
* Payable = the customer paid for something EDR did not deliver, so the money
* is a refund liability rather than revenue: cancellation fees, and prepaid
* invoices whose booking died.
* RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
* first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
* row with NO invoice while it is UNBILLED (a shipping-line booking raises no
* invoice at all), and an open batch invoice once finance bills it. Counting
* only the second understates the debt by everything not yet batched. Ordinary
* open invoice balances are the third shape — including the wagon-cancellation
* FEE, which is money the customer owes EDR, never a liability.
*
* PAYABLE — the customer paid and did not get the service. Wagon cancellation
* never refunds cash: the cancelled freight becomes a rebooking credit that is
* redeemed by creating another booking (see BookingWagonCancellationService).
* So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE —
* fee settled, wagons freed, credit not yet applied — valued at `credit_amount`,
* and it disappears the moment the row turns REBOOKED. The source invoice is
* useless for this: a whole-booking cut leaves it PAID at its full amount
* forever, which is neither the right number nor the right lifetime.
*
* Fully settled invoices are not rows here. A zero-exposure invoice is neither
* a receivable nor a payable; Invoicing Pipeline is the report that lists them.
*/
const SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_CREDIT'
WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION'
WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
THEN 'PAYABLE_UNDELIVERED'
WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN'
ELSE 'SETTLED'
END`;
const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label]));
const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR}
${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')}
/** Labels a side key that is already a column — the union is classified inside, labelled outside. */
const SIDE_LABEL_OF = (keyExpr: string): string =>
`CASE ${keyExpr}\n ${[...LABELS]
.map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`)
.join('\n ')}\nEND`;
/**
* Statuses that cannot become cash. EXPIRED closed its own pay window and
* REFUNDED already gave the money back, so neither is owed in either
* direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES —
* that constant feeds every revenue report and those invoices did earn revenue.
*/
const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')";
/**
* A booking whose money is accounted for by the cancellation ledger instead.
* Without this, a whole-booking wagon cancellation would be counted twice: once
* as its own CREDIT_AVAILABLE credit, and again as the source booking's paid
* invoice sitting against a CANCELLED booking — and the second copy would never
* clear, because rebooking updates the ledger row, not the old invoice.
*/
const HAS_CANCELLATION_LEDGER = `EXISTS (
SELECT 1 FROM freight.booking_wagon_cancellations bwc0
WHERE bwc0.booking_id = b.id
AND bwc0.deleted_at IS NULL
AND bwc0.status <> 'WITHDRAWN'
)`;
/** Customer paid, booking died, and no cancellation credit represents it. */
const PREPAID_DEAD = `i.paid_amount > 0
AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
AND NOT ${HAS_CANCELLATION_LEDGER}`;
export const INVOICE_SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_SL_INVOICED'
WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID'
ELSE 'RECEIVABLE_OPEN'
END`;
/** Money at stake on this row: what is owed, or what may have to be given back. */
const EXPOSURE = `CASE
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount
ELSE i.balance_amount
END`;
/**
* The union's column contract, in positional order.
*
* UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
* it hoists a branch's repeated expressions to the front, which silently
* rearranged one branch into `gross, exposure, side_key, …` and failed with
* "UNION types text and numeric cannot be matched". Every branch is therefore
* re-projected through this list by name before it is unioned.
*/
const UNION_COLUMNS = [
'side_key',
'txn_date',
'doc_ref',
'booking_ref',
'booking_status',
'payer',
'gross',
'settled',
'exposure',
] as const;
/**
* The one wagon-cancellation status that is a live liability: the fee is
* settled and the booking cut, but the credit has not been turned into a
* booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and
* WITHDRAWN/EXPIRED owe nothing.
*/
export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE';
/**
* Shipping-line credit status that is debt with no invoice behind it. BILLED
* credits are counted through their invoice on branch A, which is what keeps
* the two shipping-line sides disjoint.
*/
export const UNINVOICED_CREDIT_STATUS = 'UNBILLED';
/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */
function applySharedFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
ctx: ReportContext,
dateExpr: string,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${dateExpr} < :dateTo`, { dateTo: params.dateTo });
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) {
qb.andWhere('dy.code = :destination', { destination: params.destination });
}
if (params.customer) {
qb.andWhere(
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
{ customer: `%${params.customer as string}%` },
);
}
// An umbrella general contract is paid once and drawn down by many orders —
// same exclusion invoiceLedgerQb applies on branch A.
qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')");
// Both branches reach their booking directly, so the direction scope is the
// plain column form, not the source_id-pointer form invoices need. A row
// whose booking is gone carries no direction to scope by and stays visible —
// the same rule applyBookingRefDirectionScope applies on branch A.
const scope = directionScopeSql('b.trade_direction', directions);
qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params);
return qb;
}
/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */
function invoiceBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return invoiceLedgerQb(ctx)
.andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`)
.andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`)
.select(INVOICE_SIDE_EXPR, 'side_key')
.addSelect(REVENUE_DATE, 'txn_date')
.addSelect('i.invoice_number', 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
.addSelect('i.total_amount', 'gross')
.addSelect('i.paid_amount', 'settled')
.addSelect(
`CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`,
'exposure',
);
}
/**
* Branch B — shipping-line services used but never invoiced.
*
* The credit row IS the debt while it is UNBILLED; BILLED rows are the ones
* behind an invoice and are already counted by branch A, so taking only
* UNBILLED here is what keeps the two shipping-line sides disjoint.
*/
function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(ShippingLineCredit, 'slc_c')
.leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id')
.where('slc_c.deleted_at IS NULL')
.andWhere('slc_c.status = :uninvoicedCreditStatus', {
uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS,
})
.andWhere('slc_c.currency = :currency', {
currency: currencyOf(ctx.params),
});
// Priced when the service was used; that is the date the debt was incurred.
applySharedFilters(qb, ctx, 'slc_c.created_at');
return qb
.select("'RECEIVABLE_SL_UNBILLED'", 'side_key')
.addSelect('slc_c.created_at', 'txn_date')
.addSelect("'—'", 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect("COALESCE(slc.name, 'Unknown')", 'payer')
.addSelect('slc_c.amount', 'gross')
.addSelect('0::numeric', 'settled')
.addSelect('slc_c.amount', 'exposure');
}
/**
* Branch C — cancelled wagons whose credit has not been rebooked.
*
* `credit_amount` is priced in the BOOKING's payment currency, not
* `fee_currency` — that one prices the cancellation fee, which is a separate
* (and opposite-signed) piece of money.
*/
function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(BookingWagonCancellation, 'bwc')
.innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id')
.where('bwc.deleted_at IS NULL')
.andWhere('bwc.status = :creditLiabilityStatus', {
creditLiabilityStatus: CREDIT_LIABILITY_STATUS,
})
.andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", {
currency: currencyOf(ctx.params),
});
// The credit exists from the moment the fee settled and the booking was cut.
applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)');
return (
qb
.select("'PAYABLE_WAGON_CREDIT'", 'side_key')
.addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date')
// numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5"
// survives, because a half wagon is a real bulk quantity here).
.addSelect(
`rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`,
'doc_ref',
)
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
// The freight was paid in full on the original booking, so the whole
// credit is money already in hand and owed back as bookable value.
.addSelect('bwc.credit_amount', 'gross')
.addSelect('bwc.credit_amount', 'settled')
.addSelect('bwc.credit_amount', 'exposure')
);
}
/**
* The three branches as one relation, wrapped so the runner can sort, page and
* COUNT(*) it like any other report query.
*
* Parameters are merged from every branch: `getQuery()` leaves `:name`
* placeholders in place, and only the outer builder's parameter bag is read
* when the SQL is finally bound.
*/
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = invoiceLedgerQb(ctx);
const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)];
const combined = branches
.map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`)
.join('\n UNION ALL\n ');
const qb = ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters(Object.assign({}, ...branches.map((b) => b.getParameters())));
const sides = ctx.params.sides as string[] | null;
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides });
if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides });
return qb;
}
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
key: 'receivables-payables',
title: 'Receivables and Payables',
description:
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' +
'including shipping-line credit services — and payable, where the customer paid but ' +
'the service was not delivered, such as cancellation fees and prepayments against ' +
'dead bookings. Payable amounts are a refund liability, not revenue.',
'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
'carrying a balance — and payable, where the customer paid and the service was not ' +
'delivered. The payable is dominated by wagon cancellations whose credit has not been ' +
'rebooked; that credit is redeemed by creating another booking, never refunded in cash.',
group: 'Finance',
filters: [
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES },
{
key: 'sides',
label: 'Ledger side',
type: 'multiselect',
options: LEDGER_SIDES,
},
],
columns: [
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{
key: 'side',
label: 'Ledger side',
type: 'string',
sortable: true,
sortExpr: 'r.side_key',
},
{
key: 'issuedAt',
label: 'Date',
type: 'date',
sortable: true,
sortExpr: 'r.txn_date',
},
{
key: 'invoiceNumber',
label: 'Invoice / ref',
type: 'string',
sortable: true,
sortExpr: 'r.doc_ref',
},
{ key: 'bookingRef', label: 'Booking', type: 'string' },
{ key: 'bookingStatus', label: 'Booking status', type: 'string' },
{ key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' },
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE },
{
key: 'customer',
label: 'Payer',
type: 'string',
sortable: true,
sortExpr: 'r.payer',
},
{
key: 'invoiced',
label: 'Amount',
type: 'money',
sortable: true,
sortExpr: 'r.gross',
},
{
key: 'paid',
label: 'Paid',
type: 'money',
sortable: true,
sortExpr: 'r.settled',
},
{
key: 'exposure',
label: 'Owed / refundable',
type: 'money',
sortable: true,
sortExpr: 'r.exposure',
},
],
defaultSort: { key: 'exposure', dir: 'DESC' },
chart: { type: 'bar', x: 'side', y: ['exposure'] },
query(ctx) {
return baseQuery(ctx)
.select(SIDE_LABEL_EXPR, 'side')
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect("COALESCE(b.status, '—')", 'bookingStatus')
.addSelect(PAYER_EXPR, 'customer')
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid')
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure');
.select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect('r.doc_ref', 'invoiceNumber')
.addSelect('r.booking_ref', 'bookingRef')
.addSelect('r.booking_status', 'bookingStatus')
.addSelect('r.payer', 'customer')
.addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
.addSelect('ROUND(r.settled, 2)::float8', 'paid')
.addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`,
"ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8",
'receivable',
)
.addSelect(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`,
"ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8",
'payable',
)
.addSelect('COUNT(*)::int', 'invoices')
.getRawOne<{ receivable: number; payable: number; invoices: number }>();
.addSelect('COUNT(*)::int', 'items')
.getRawOne<{ receivable: number; payable: number; items: number }>();
const receivable = Number(row?.receivable ?? 0);
const payable = Number(row?.payable ?? 0);
const currency = currencyOf(ctx.params);
return [
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency },
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Receivable', value: receivable, unit: currency },
{ label: 'Payable', value: payable, unit: currency },
{
label: 'Net position',
value: Math.round(receivable - payable),
unit: currency,
},
{ label: 'Open items', value: Number(row?.items ?? 0) },
];
},
};

View File

@@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types';
import {
AVG_PER_UNIT_EXPR,
CATEGORY_LABEL_EXPR,
CATEGORY_LABEL_OF,
CONTAINERS_EXPR,
PERIOD_FILTER,
REVENUE_CATEGORIES,
REVENUE_CATEGORY_EXPR,
REVENUE_FILTERS,
REVENUE_SUM,
@@ -21,19 +22,35 @@ import {
const REVENUE = 'SUM(il.amount)';
/**
* Previous period's revenue for the same category.
* Previous period's revenue for the same category, over the zero-filled grid.
*
* Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is
* legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the
* ORDER BY must repeat their grouping expressions verbatim: ordering by the
* inner `date_trunc` when the group key is the `to_char` wrapper fails, and
* ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window
* clause, silently producing an unordered partition.
* The window runs in the OUTER query, not alongside the aggregate. `lag()` only
* ever sees the rows its own query level produces, so computing it inside the
* aggregate would skip straight over a category's silent periods — a category
* billed in January and March would read March's prior as January and report
* flat growth, hiding the month it earned nothing. Against the grid, February
* exists at zero and both comparisons are real.
*/
const priorRevenue = (period: string): string =>
`lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.period)';
const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period));
/**
* Every category the grid must carry, narrowed to the caller's selection.
*
* This is where the `categories` filter is enforced for the table — the grid
* lists only what the caller asked for, and the join back to the aggregate
* drops the rest. See {@link revenueByCategoryReport.query} for why the filter
* cannot also be left on the aggregate.
*
* Intersected in JS against the constant list rather than interpolating the
* request's own values: the grid spells its categories into the SQL text, and a
* user-supplied string must never land there. An unrecognised value simply
* drops out — the ledger would match nothing on it anyway.
*/
const gridCategoryKeys = (params: Record<string, unknown>): string[] => {
const selected = params.categories as string[] | null;
const all = REVENUE_CATEGORIES.map((c) => c.value);
return selected?.length ? all.filter((key) => selected.includes(key)) : all;
};
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return revenueLedgerQb(ctx);
@@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = {
title: 'Revenue by Category',
description:
'Billed revenue in the twelve rail revenue categories, per period, with volume and ' +
'period-over-period growth. Growth compares against the previous period inside the ' +
'period-over-period growth. Every category is listed in every period that has revenue, ' +
'at zero when it was not billed, so a category going quiet reads as a drop rather than ' +
'a missing row. Growth compares against the previous period inside the ' +
'selected date range, so the earliest period always reads zero. ' +
'Multimodal means a named sea carrier is on the booking.',
group: 'Finance',
@@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = {
},
query(ctx) {
const period = periodExpr(ctx.params);
return baseQuery(ctx)
/*
* One row per period/category that actually has lines. Revenue stays
* unrounded here so the growth window below divides the same numbers the
* old single-level query did; the display rounding happens in the wrapper.
*
* The category filter is deliberately dropped from this aggregate and
* applied by the grid instead. The period axis is built from whatever
* periods this aggregate produces, so filtering here would make the axis
* depend on the selection — pick a category that was never billed and
* there would be no periods left to hang its zero rows on, which is
* exactly the empty table the grid exists to prevent. Unselected
* categories still cost nothing: the grid never lists them, so the join
* drops them.
*/
const agg = revenueLedgerQb({ ...ctx, params: { ...ctx.params, categories: null } })
.select(period, 'period')
.addSelect(CATEGORY_LABEL_EXPR, 'category')
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey')
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
.addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct')
.addSelect(REVENUE_CATEGORY_EXPR, 'category_key')
.addSelect(REVENUE, 'revenue')
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
.addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers')
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit')
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avg_per_unit')
.addSelect(UNIT_LABEL_EXPR, 'unit')
.addSelect('COUNT(*)::int', 'lines')
.groupBy(period)
.addGroupBy(REVENUE_CATEGORY_EXPR);
const categoryKeys = gridCategoryKeys(ctx.params)
.map((key) => `'${key}'`)
.join(', ');
/*
* The grid: every period that has revenue at all, crossed with every
* category the filter allows, then LEFT JOINed back to the aggregate so an
* unbilled category lands at zero instead of vanishing.
*
* Periods come from the data, NOT from generate_series over the date
* filter. A default twelve-month range over a database with one billed
* month would otherwise publish eleven months of pure zeros, and a daily
* granularity would multiply that by thirty. A period that saw no revenue
* in ANY category is still absent; a category that saw none in a live
* period is not — and because the aggregate above ignores the category
* filter, "live" means live for the business, not live for the selection.
*
* `unnest(ARRAY[...])` rather than `VALUES` because an empty array is legal
* and yields no rows — `VALUES` with nothing in it is a syntax error, and a
* filter naming only unrecognised categories produces exactly that list.
*/
const grid = `
WITH agg AS (${agg.getQuery()})
SELECT g.period,
g.category_key,
COALESCE(a.revenue, 0) AS revenue,
COALESCE(a.tons, 0) AS tons,
COALESCE(a.teu, 0) AS teu,
COALESCE(a.containers, 0) AS containers,
COALESCE(a.avg_per_unit, 0) AS avg_per_unit,
COALESCE(a.unit, '') AS unit,
COALESCE(a.lines, 0) AS lines
FROM (
SELECT p.period, c.category_key
FROM (SELECT DISTINCT period FROM agg) p
CROSS JOIN unnest(ARRAY[${categoryKeys}]::text[]) AS c(category_key)
) g
LEFT JOIN agg a ON a.period = g.period AND a.category_key = g.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${grid})`, 'r')
.setParameters(agg.getParameters())
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('ROUND(r.revenue)::float8', 'revenue')
.addSelect(`ROUND(COALESCE(${PRIOR_REVENUE}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPctExpr('r.revenue', PRIOR_REVENUE)}, 0)`, 'growthPct')
.addSelect('r.tons::float8', 'tons')
.addSelect('r.teu::int', 'teu')
.addSelect('r.containers::int', 'containers')
.addSelect('r.avg_per_unit::float8', 'avgPerUnit')
.addSelect('r.unit', 'unit')
.addSelect('r.lines::int', 'lines');
},
async summary(ctx) {
const row = await baseQuery(ctx)
@@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = {
const currency = currencyOf(ctx.params);
return [
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency },
{ label: 'Categories', value: Number(row?.categories ?? 0) },
// "with revenue" is not decoration: the table now lists every category in
// every live period, so a bare "Categories: 6" next to fourteen rows
// would read as a contradiction rather than as the count of live ones.
{ label: 'Categories with revenue', value: Number(row?.categories ?? 0) },
// Always shown, even at zero: an audit report must never quietly drop money.
{ label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency },
];

View File

@@ -1,91 +1,101 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportContext, ReportColumn, ReportDefinition } from '../report.types';
import {
PAID_SHARE,
PAYER_EXPR,
PAYMENT_CLASSES,
PAYMENT_CLASS_EXPR,
REVENUE_FILTERS,
REVENUE_SUM,
currencyOf,
revenueLedgerQb,
} from '../revenue-classification';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
/**
* One column per payment class, pivoted with FILTER. The class values are the
* compile-time constants in PAYMENT_CLASSES, never user input, so they are
* safe to interpolate.
*/
const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({
value: c.value,
key: c.value.toLowerCase().replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
label: c.label,
}));
const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({
key: c.key,
label: c.label,
type: 'money',
sortable: true,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
return revenueLedgerQb(ctx);
}
export const revenueByCustomerReport: ReportDefinition = {
key: 'revenue-by-customer',
title: 'Revenue by Customer',
description: 'Ranked customers by booking revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
description:
'Every paying customer on one row: total billed revenue, what they have settled, ' +
'what is still open, and a column per charge type — rail transport, customs ' +
'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' +
'and unloading, and additional charges. Built on invoice lines, so the charge-type ' +
'split is the billed one; a booking total is a lump sum and cannot be split. The ' +
'payer is the company or, for shipping-line credit invoices, the shipping line. ' +
'There is no dedicated loading/unloading charge type in the system — handling, ' +
'double-handling and lashing stand in for it.',
group: 'Finance',
filters: REVENUE_FILTERS,
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
{
key: 'customer',
label: 'Customer',
type: 'string',
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: 'revenue', label: 'Total revenue', type: 'money', sortable: true },
{ key: 'paid', label: 'Paid', type: 'money', sortable: true },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
...classMoneyColumns,
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'customer', y: ['revenue'] },
drill: { to: 'revenue-transactions', carry: { customer: 'customer' } },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('c.name');
const qb = baseQuery(ctx)
.select(PAYER_EXPR, 'customer')
.addSelect(REVENUE_SUM, 'revenue')
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding')
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
.groupBy(PAYER_EXPR);
for (const c of CLASS_COLUMNS) {
qb.addSelect(
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`,
c.key,
);
}
return qb;
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT c.name)::int', 'customers')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.getRawOne();
.select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.addSelect(REVENUE_SUM, 'revenue')
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.getRawOne<{ customers: number; revenue: number; paid: number }>();
const revenue = Number(row?.revenue ?? 0);
const paid = Number(row?.paid ?? 0);
const unit = currencyOf(ctx.params);
return [
{ label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
{ label: 'Total revenue', value: revenue, unit },
{ label: 'Paid', value: paid, unit },
{ label: 'Outstanding', value: Math.round(revenue - paid), unit },
];
},
};

View File

@@ -1,62 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const revenueSummaryReport: ReportDefinition = {
key: 'revenue-summary',
title: 'Revenue Summary',
description: 'Booking revenue by direction, cargo type and currency',
group: 'Finance',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('b.payment_currency', 'currency')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type')
.addGroupBy('b.payment_currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,6 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportContext, ReportDefinition } from "../report.types";
import {
CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR,
@@ -10,12 +10,13 @@ import {
OPERATIONS_FILTERS,
TEU_EXPR,
allocationLedgerQb,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
plannedRowsSql,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
} from "../operations-classification";
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification";
const CONTAINERS_20 = `COALESCE(SUM((
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
@@ -39,44 +40,53 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
}
export const teuPerformanceReport: ReportDefinition = {
key: 'teu-performance',
title: 'TEU Performance',
key: "teu-performance",
title: "TEU Performance",
description:
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' +
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' +
'marshalling record — the containers actually allocated to wagons — not from the ' +
'billing lines. Plan comes from Operational targets.' +
"Twenty-foot equivalent units moved per container class against plan. Every 40ft box " +
"counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the " +
"marshalling record — the containers actually allocated to wagons — not from the " +
"billing lines. Plan comes from Operational targets." +
PLAN_GRANULARITY_NOTE,
group: 'Operations',
group: "Operations",
filters: [
PERIOD_FILTER,
...OPERATIONS_FILTERS,
{ key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES },
{ key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES },
],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{ key: 'containerClass', label: 'Container type', type: 'string', sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: "period", label: "Period", type: "string", sortable: true },
{ key: "containerClass", label: "Container type", type: "string", sortable: true },
{ key: "containers20", label: "20ft", type: "number", sortable: true },
{ key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: "plan", label: "Plan", type: "number" },
{ key: "planRequired", label: "Required", type: "number" },
{ key: "implementRate", label: "Implement rate", type: "percent" },
],
defaultSort: { key: 'operated', dir: 'DESC' },
chart: { type: 'bar', x: 'containerClass', y: ['operated'] },
defaultSort: { key: "operated", dir: "DESC" },
chart: { type: "bar", x: "containerClass", y: ["operated"] },
query(ctx) {
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
.addSelect(CONTAINER_CLASS_EXPR, 'class_key')
.addSelect(CONTAINERS_20, 'containers20')
.addSelect(CONTAINERS_40, 'containers40')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect(TEU_EXPR, 'operated')
.select(periodExprOn(OPS_DATE, ctx.params), "period")
.addSelect(CONTAINER_CLASS_EXPR, "class_key")
.addSelect(CONTAINERS_20, "containers20")
.addSelect(CONTAINERS_40, "containers40")
.addSelect(TEU_EXPR, "operated")
.groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR);
// Attainment for the cascade: TEU across the target's whole period, so a
// mid-year view does not read as "nothing shipped yet".
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), "bucket")
.addSelect(CONTAINER_CLASS_EXPR, "act_key")
.addSelect("NULL::varchar", "act_category")
.addSelect(TEU_EXPR, "actual")
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CONTAINER_CLASS_EXPR);
// Full outer join so a planned container class that never moved still
// reports, at zero rather than vanishing.
const combined = `
@@ -84,38 +94,47 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.class_key, p.plan_key) AS class_key,
COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.containers, 0) AS containers,
COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan
p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p
FULL OUTER JOIN (${plannedRowsSql(
"TEU",
"container_class",
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.select('r.period', 'period')
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass')
.addSelect('r.class_key', 'containerClassKey')
.addSelect('r.containers20::int', 'containers20')
.addSelect('r.containers40::int', 'containers40')
.addSelect('r.containers::int', 'containers')
.addSelect('r.operated::int', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
.from(`(${combined})`, "r")
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select("r.period", "period")
.addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect("r.class_key", "containerClassKey")
.addSelect("r.containers20::int", "containers20")
.addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan")
.addSelect("r.plan_required::float8", "planRequired")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(TEU_EXPR, 'teu')
.addSelect(CONTAINERS_EXPR, 'containers')
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
.select(TEU_EXPR, "teu")
.addSelect(CONTAINERS_EXPR, "containers")
.addSelect("COUNT(DISTINCT ts.id)::int", "trains")
.getRawOne<{ teu: number; containers: number; trains: number }>();
return [
{ label: 'TEU', value: Number(row?.teu ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Trains', value: Number(row?.trains ?? 0) },
{ label: "TEU", value: Number(row?.teu ?? 0) },
{ label: "Containers", value: Number(row?.containers ?? 0) },
{ label: "Trains", value: Number(row?.trains ?? 0) },
];
},
};

View File

@@ -13,6 +13,7 @@ import {
TRAINSETS_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' },
{ key: 'planRequired', label: 'Required', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
],
defaultSort: { key: 'operated', dir: 'DESC' },
@@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = {
.groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade: the same trainset measure across the target's
// whole period, not just the window the viewer is looking at.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(TRAINSETS_EXPR, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// FULL OUTER JOIN so a category that was planned but never ran still shows,
// at zero — TypeORM's builder has no full-outer join, hence the raw text.
const combined = `
@@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = {
COALESCE(o.trains, 0) AS trains,
COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan
p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p
FULL OUTER JOIN (${plannedRowsSql(
'TRAINSET',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
@@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
.addSelect('r.wagons::int', 'wagons')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
},
async summary(ctx) {

View File

@@ -503,21 +503,68 @@ export function applyCategoryFilter(
}
/**
* The planned rows for a metric, as a derived table.
* Appended to every plan-versus-actual report's description, because neither
* the re-bucketing nor the catch-up rule is guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both. ' +
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
/**
* How long one target's period runs. A target's span is exact — 90 days is 90
* days — and need not line up with the ragged year-end display blocks the
* `nine_month` and `ninety_day` granularities produce. The spread below is
* proportional, so partial overlap resolves correctly either way.
*/
const TARGET_SPAN = `CASE ot.period_type
WHEN 'day' THEN INTERVAL '1 day'
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'half_year' THEN INTERVAL '6 months'
WHEN 'nine_month' THEN INTERVAL '9 months'
WHEN 'ninety_day' THEN INTERVAL '90 days'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END`;
/**
* The planned rows for a metric, as a derived table: one row per bucket per
* planned key, carrying both a committed and a required figure.
*
* A target is a rate over its own period, not a lump at its start: the plan is
* spread evenly across the days it covers, then re-gathered into the report's
* buckets. One rule covers every direction — three monthly targets add up to a
* **Plan** — a target is a rate over its own period, not a lump at its start.
* The committed value is spread evenly across the days it covers and
* re-gathered into the report's buckets, so three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both months.
* straddling a month boundary draws proportionally on both. The even spread is
* an assumption, and the only one available: a monthly figure carries no
* information about which days inside it were busier. This number never moves —
* Implement Rate is measured against it, so a month that missed keeps reading
* as a month that missed.
*
* The even spread is an assumption, and the only one available: a monthly
* figure carries no information about which days inside it were busier.
* **Required** — the same target read as a quota. At each bucket, whatever is
* still outstanding (committed minus everything delivered in earlier buckets)
* is spread across the time still left in the period. A year 20% met at the
* halfway mark asks the remaining months for the other 80%. Over-delivery
* clamps to zero rather than going negative: a met quota requires nothing more.
*
* The share is clipped to the user's date filter as well as to the bucket, so
* the plan always covers exactly the span the operated figure beside it covers.
* Without that, filtering to July and viewing by year would put a whole year's
* plan next to one month's work.
* `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* be built **without the user's date bounds** — see {@link attainmentCtx}.
* Attainment is a fact about the target's whole period; measuring it through
* the report's date filter would read a mid-year view as "nothing delivered
* yet" and demand the entire year's work from one month.
*
* The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report
@@ -528,62 +575,96 @@ export function applyCategoryFilter(
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter.
*/
/**
* Appended to every plan-versus-actual report's description, because the
* re-bucketing rule is not guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
export const plannedRowsSql = (
metric: string,
dimension: string,
params: Record<string, unknown>,
actualsSql: string,
): string => {
const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period,
ot.dimension_key AS plan_key,
ot.cargo_category AS plan_category,
SUM(ot.planned_value * (
GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO})
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM}))))
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0)
)) AS plan_value
WITH tgt AS (
SELECT ot.id,
ot.dimension_key,
ot.cargo_category,
ot.planned_value,
ot.period_start::timestamptz AS starts,
ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
FROM freight.operations_targets ot
CROSS JOIN LATERAL (
SELECT ot.period_start + CASE ot.period_type
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END AS ends
) t
CROSS JOIN LATERAL generate_series(
date_trunc('${unit.trunc}', ot.period_start::timestamptz),
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'),
INTERVAL '${unit.step}'
) AS g(bucket)
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM}
AND g.bucket < ${PLAN_TO}
GROUP BY 1, 2, 3
HAVING SUM(ot.planned_value) > 0`;
AND ot.planned_value > 0
),
-- One row per target per bucket. Generated a day at a time rather than a
-- bucket at a time: the ragged units restart their blocks each January, so
-- stepping by the unit's own width walks off the anchor in the second year.
-- Day grain also makes a bucket that only partly overlaps the target fall out
-- for free, at the same sub-day precision the clipping used before.
spread AS (
SELECT t.id,
t.dimension_key,
t.cargo_category,
t.planned_value,
EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
${bucketOf} AS bucket,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends)
- GREATEST(d.day, t.starts))))) AS secs_full,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
- GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in
FROM tgt t
CROSS JOIN LATERAL generate_series(
date_trunc('day', t.starts),
t.ends - INTERVAL '1 microsecond',
INTERVAL '1 day'
) AS d(day)
GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value,
t.starts, t.ends, ${bucketOf}
),
-- secs_before and actual_before are strictly-preceding running sums, so a
-- bucket's requirement is decided by what happened before it, never by its
-- own result. The frame is spelled out rather than defaulted: the default
-- RANGE frame would fold peer rows into the current one.
cascaded AS (
SELECT s.*,
COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before,
COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before
FROM spread s
LEFT JOIN (${actualsSql}) a
ON a.bucket = s.bucket
AND a.act_key = s.dimension_key
AND a.act_category IS NOT DISTINCT FROM s.cargo_category
WINDOW prior AS (
PARTITION BY s.id ORDER BY s.bucket
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
)
SELECT ${unit.labelOn('c.bucket')} AS period,
c.dimension_key AS plan_key,
c.cargo_category AS plan_category,
SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value,
SUM(GREATEST(0, c.planned_value - c.actual_before)
* c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required
FROM cascaded c
WHERE c.secs_in > 0
GROUP BY 1, 2, 3`;
};
/**
* The report's own ledger with the user's date bounds removed, for the
* attainment series {@link plannedRowsSql} cascades from. Every other filter
* stays applied, so the catch-up figure is measured on the same population as
* the `operated` column it sits beside.
*/
export const attainmentCtx = (ctx: ReportContext): ReportContext => ({
...ctx,
params: { ...ctx.params, dateFrom: null, dateTo: null },
});
/** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = (
params: Record<string, unknown>,

View File

@@ -1,45 +1,39 @@
import { ReportKey } from '../../seed/freight-permissions.registry';
import { bookingsListReport } from './definitions/bookings-list.report';
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
import { agingReceivablesReport } from './definitions/aging-receivables.report';
import { contractUtilizationReport } from './definitions/contract-utilization.report';
import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report';
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report';
import { wagonRequestsReport } from './definitions/wagon-requests.report';
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report';
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report';
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report';
import { trainTurnaroundReport } from './definitions/train-turnaround.report';
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report';
import { loadedCapacityReport } from './definitions/loaded-capacity.report';
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report';
import { customerStatusReport } from './definitions/customer-status.report';
import { contractLifecycleReport } from './definitions/contract-lifecycle.report';
import { customsDocumentsReport } from './definitions/customs-documents.report';
import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report';
import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report';
import { invoicesByStatusReport } from './definitions/invoices-by-status.report';
import { paymentsByStatusReport } from './definitions/payments-by-status.report';
import { revenueSummaryReport } from './definitions/revenue-summary.report';
import { cargoSummaryReport } from './definitions/cargo-summary.report';
import { revenueByCategoryReport } from './definitions/revenue-by-category.report';
import { revenueTransactionsReport } from './definitions/revenue-transactions.report';
import { revenueByPeriodReport } from './definitions/revenue-by-period.report';
import { revenueByRouteReport } from './definitions/revenue-by-route.report';
import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report';
import { paymentClassificationReport } from './definitions/payment-classification.report';
import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report';
import { receivablesPayablesReport } from './definitions/receivables-payables.report';
import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report';
import { stationStayingTimeReport } from './definitions/station-staying-time.report';
import { turnaroundCycleReport } from './definitions/turnaround-cycle.report';
import { trainDelaysReport } from './definitions/train-delays.report';
import { trainsetPerformanceReport } from './definitions/trainset-performance.report';
import { teuPerformanceReport } from './definitions/teu-performance.report';
import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report';
import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report';
import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report';
import { ReportDefinition } from './report.types';
import { ReportKey } from "../../seed/freight-permissions.registry";
import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report";
import { agingReceivablesReport } from "./definitions/aging-receivables.report";
import { contractUtilizationReport } from "./definitions/contract-utilization.report";
import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report";
import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report";
import { wagonRequestsReport } from "./definitions/wagon-requests.report";
import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report";
import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report";
import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report";
import { trainTurnaroundReport } from "./definitions/train-turnaround.report";
import { wagonTeuUtilizationReport } from "./definitions/wagon-teu-utilization.report";
import { loadedCapacityReport } from "./definitions/loaded-capacity.report";
import { globalLogisticsWagonsReport } from "./definitions/global-logistics-wagons.report";
import { customsDocumentsReport } from "./definitions/customs-documents.report";
import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report";
import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.report";
import { cargoSummaryReport } from "./definitions/cargo-summary.report";
import { revenueByCategoryReport } from "./definitions/revenue-by-category.report";
import { revenueTransactionsReport } from "./definitions/revenue-transactions.report";
import { revenueByPeriodReport } from "./definitions/revenue-by-period.report";
import { revenueByRouteReport } from "./definitions/revenue-by-route.report";
import { revenueTopCustomersReport } from "./definitions/revenue-top-customers.report";
import { paymentClassificationReport } from "./definitions/payment-classification.report";
import { revenueReconciliationReport } from "./definitions/revenue-reconciliation.report";
import { receivablesPayablesReport } from "./definitions/receivables-payables.report";
import { revenueAnomaliesReport } from "./definitions/revenue-anomalies.report";
import { stationStayingTimeReport } from "./definitions/station-staying-time.report";
import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report";
import { trainDelaysReport } from "./definitions/train-delays.report";
import { trainsetPerformanceReport } from "./definitions/trainset-performance.report";
import { teuPerformanceReport } from "./definitions/teu-performance.report";
import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report";
import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
import { ReportDefinition } from "./report.types";
/**
* Every report the platform knows about. Adding one = a new file under
@@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types';
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
*/
export const REPORTS: ReportDefinition[] = [
bookingsListReport,
revenueByCustomerReport,
agingReceivablesReport,
contractUtilizationReport,
@@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [
wagonTeuUtilizationReport,
loadedCapacityReport,
globalLogisticsWagonsReport,
customerStatusReport,
contractLifecycleReport,
customsDocumentsReport,
invoicingPipelineReport,
firstLastMileBookingsReport,
invoicesByStatusReport,
paymentsByStatusReport,
revenueSummaryReport,
cargoSummaryReport,
revenueByCategoryReport,
revenueTransactionsReport,
@@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [
cargoVolumeByStationReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
const BY_KEY = new Map<ReportKey, ReportDefinition>(
REPORTS.map((r) => [r.key, r]),
);
export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey);

View File

@@ -86,17 +86,54 @@ describe('revenue classification', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'.
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain(
"date_trunc('month'",
);
const injection = "day'); DROP TABLE freight.invoices; --";
expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'");
});
it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered.length).toBe(5);
for (const unit of offered) {
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`);
expect(offered).toEqual([
'day',
'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
]);
// Every offered unit resolves to its own expression rather than silently
// falling through to the month default — which is what a missing entry or a
// typo'd key would look like.
const expressions = offered.map((unit) => periodExpr({ period: unit }));
expect(new Set(expressions).size).toBe(offered.length);
});
/**
* Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are
* offset arithmetic anchored to January 1st. These pin the anchor: they are
* the SQL half of a pair whose other half is `normalisePeriodStart` in
* `operations-targets.service.ts`, and a target that snaps to a boundary the
* report does not bucket on plans against a period that does not exist.
*/
it('anchors the irregular units to the start of the calendar year', () => {
for (const unit of ['half_year', 'nine_month', 'ninety_day']) {
const expr = periodExpr({ period: unit });
expect(expr).toContain("date_trunc('year'");
expect(expr).not.toContain(`date_trunc('${unit}'`);
}
// Six- and nine-month blocks count whole months from January.
expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'");
expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'");
// 90-day blocks count days, and cap at the fourth so the last days of
// December widen block four instead of forming a 5-day stub of their own.
const ninety = periodExpr({ period: 'ninety_day' });
expect(ninety).toContain("INTERVAL '90 days'");
expect(ninety).toContain('LEAST(');
expect(ninety).toContain('/ 90, 3)');
});
});

View File

@@ -139,8 +139,15 @@ const labelCase = (expr: string, options: ReportFilterOption[]): string =>
.map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`)
.join('\n ')}\nEND`;
/**
* The same labelling applied to a key that is already a column — for reports
* that classify in a subquery and label in the wrapper.
*/
export const CATEGORY_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, REVENUE_CATEGORIES);
/** The category as a business label rather than its key, for display columns. */
export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES);
export const CATEGORY_LABEL_EXPR = CATEGORY_LABEL_OF(REVENUE_CATEGORY_EXPR);
/**
* Period-over-period change, as a percentage.
@@ -223,22 +230,103 @@ END`;
// ---------------------------------------------------------------------------
/**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string
* or null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of five compile-time constants does.
* A granularity, as SQL builders rather than fragments to interpolate.
*
* Every format is zero-padded, so lexicographic order equals chronological
* order. The growth window depends on that.
* Five of the eight are plain `date_trunc` units. The other three — half-year,
* nine-month, ninety-day — have no `date_trunc` equivalent in Postgres, so they
* are offset arithmetic from the start of the calendar year. Builders let both
* kinds live behind one interface.
*/
const PERIOD_UNITS = {
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' },
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' },
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' },
interface PeriodUnit {
label: string;
/** Interval one whole block wide. Only exact for the six regular units. */
step: string;
/** Timestamp expression → the start of the block that timestamp falls in. */
truncOn: (dateExpr: string) => string;
/** Block-start expression → its display label. */
labelOn: (truncExpr: string) => string;
/**
* Block-start expression → the start of the NEXT block. Not always
* `+ step`: a ragged unit's final block of the year is shorter than its own
* step, so stepping past it overshoots into the wrong block.
*/
nextStartOn: (truncExpr: string) => string;
}
const regular = (trunc: string, fmt: string, label: string, step: string): PeriodUnit => ({
label,
step,
truncOn: (dateExpr) => `date_trunc('${trunc}', ${dateExpr})`,
labelOn: (truncExpr) => `to_char(${truncExpr}, '${fmt}')`,
nextStartOn: (truncExpr) => `(${truncExpr} + INTERVAL '${step}')`,
});
/**
* Blocks of `months` months counted from January, so they reset every calendar
* year. Six divides twelve and nine does not: a nine-month year is JanSep plus
* a short OctDec. That ragged tail is inherent to the unit — the alternative
* is blocks that drift out of the calendar, which is not what "calendar
* anchored" means.
*/
const monthBlocks = (months: number, marker: string, label: string): PeriodUnit => ({
label,
step: `${months} months`,
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (((EXTRACT(MONTH FROM ${dateExpr})::int - 1) / ${months}) * INTERVAL '${months} months'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-${marker}' ||` +
` ((EXTRACT(MONTH FROM ${truncExpr})::int - 1) / ${months} + 1)::text)`,
nextStartOn: (truncExpr) =>
`LEAST(${truncExpr} + INTERVAL '${months} months',` +
` date_trunc('year', ${truncExpr}) + INTERVAL '1 year')`,
});
/**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string or
* null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of eight compile-time constants does.
*
* Every label is zero-padded or single-digit-bounded, so lexicographic order
* equals chronological order. The growth windows depend on that.
*/
const PERIOD_UNITS: Record<string, PeriodUnit> = {
day: regular('day', 'YYYY-MM-DD', 'Daily', '1 day'),
week: regular('week', 'IYYY-"W"IW', 'Weekly', '1 week'),
month: regular('month', 'YYYY-MM', 'Monthly', '1 month'),
// `quarter` is a valid date_trunc unit but NOT a valid interval unit —
// INTERVAL '1 quarter' is a syntax error, so the step is spelled in months.
quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' },
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' },
} as const;
quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'),
half_year: monthBlocks(6, 'H', 'Half-yearly'),
nine_month: monthBlocks(9, 'N', 'Nine-monthly'),
/**
* Four 90-day blocks from January 1st: days 1, 91, 181, 271.
*
* The block index is capped at 3 on purpose. Uncapped, `(doy - 1) / 90` puts
* December 27th onwards in a fifth block — a 5-day stub bucket at the end of
* every year, which is noise rather than a period. Capping instead lets the
* fourth block absorb the remainder and run 95 or 96 days.
*
* The label carries the zero-padded start day-of-year, which keeps it sorting
* chronologically and — unlike an ordinal — says out loud that the blocks are
* day-counted rather than month-aligned.
*/
ninety_day: {
label: '90-day',
step: '90 days',
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (LEAST((EXTRACT(DOY FROM ${dateExpr})::int - 1) / 90, 3) * INTERVAL '90 days'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-D' || lpad(EXTRACT(DOY FROM ${truncExpr})::int::text, 3, '0'))`,
// The fourth block ends with the year, not 90 days after it started.
nextStartOn: (truncExpr) =>
`(CASE WHEN EXTRACT(DOY FROM ${truncExpr})::int >= 271` +
` THEN date_trunc('year', ${truncExpr}) + INTERVAL '1 year'` +
` ELSE ${truncExpr} + INTERVAL '90 days' END)`,
},
year: regular('year', 'YYYY', 'Yearly', '1 year'),
};
export const PERIOD_FILTER: ReportFilterDef = {
key: 'period',
@@ -267,10 +355,8 @@ export function periodExpr(params: Record<string, unknown>): string {
return periodExprOn(REVENUE_DATE, params);
}
export function resolvePeriod(
params: Record<string, unknown>,
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
export function resolvePeriod(params: Record<string, unknown>): PeriodUnit {
const key = String(params.period ?? '');
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month;
}
@@ -280,10 +366,10 @@ export function resolvePeriod(
* these units so a month means the same thing on both sides of the product.
*/
export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`;
resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params));
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`;
resolvePeriod(params).truncOn(dateExpr);
/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */
export const periodTruncExpr = (params: Record<string, unknown>): string =>
@@ -298,9 +384,16 @@ export const periodTruncExpr = (params: Record<string, unknown>): string =>
export const periodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
/** Same scale, one period later — where a one-step-ahead projection lands. */
/**
* Same scale, one period later — where a one-step-ahead projection lands.
*
* Asks the unit rather than adding its step, because the two differ for the
* ragged units: a nine-month year's second block is three months long, and a
* 90-day year's fourth is 95, so `+ step` would land past the next block start
* and evaluate the regression at the wrong x.
*/
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`;
`EXTRACT(EPOCH FROM ${resolvePeriod(params).nextStartOn(periodTruncExpr(params))})`;
// ---------------------------------------------------------------------------
// Volume — measured at line grain, never joined from the booking

View File

@@ -17,7 +17,9 @@ import {
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingEditTrainNumber,
TrainSchedulingLoad,
TrainSchedulingReschedule,
TrainSchedulingUnload,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
@@ -612,7 +614,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/load")
@TrainSchedulingUpdate()
@TrainSchedulingLoad()
@ApiOperation({
summary:
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
@@ -625,7 +627,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/unload")
@TrainSchedulingUpdate()
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
@@ -638,7 +640,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingUpdate()
@TrainSchedulingLoad()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
@@ -650,7 +652,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingUpdate()
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",

View File

@@ -91,4 +91,18 @@ export class ListWagonsQueryDto {
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Last maintenance flip on or after this day (YYYY-MM-DD)',
})
@IsOptional()
@IsDateString()
maintenanceFrom?: string;
@ApiPropertyOptional({
description: 'Last maintenance flip on or before this day (YYYY-MM-DD)',
})
@IsOptional()
@IsDateString()
maintenanceTo?: string;
}

View File

@@ -90,6 +90,27 @@ export class WagonsService {
});
}
// Last-maintenance range, both ends inclusive. There's no column to
// compare directly — "last maintenance" is the latest status-log flip to
// MAINTENANCE (see attachStatusDates below), so this mirrors that same
// MAX(...) FILTER(...) as a correlated subquery against the same table.
if (query.maintenanceFrom) {
qb.andWhere(
`(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}')
>= CAST(:maintenanceFrom AS date)`,
{ maintenanceFrom: query.maintenanceFrom },
);
}
if (query.maintenanceTo) {
qb.andWhere(
`(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}')
< CAST(:maintenanceTo AS date) + INTERVAL '1 day'`,
{ maintenanceTo: query.maintenanceTo },
);
}
// Search matches the wagon number or either run number.
if (search) {
qb.andWhere(

View File

@@ -2,10 +2,15 @@ import {
BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
deriveReadPermissions,
FREIGHT_PERMS,
POSITION_PERMISSION_PRESETS,
ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry';
/** Shorthand for the one overview-layout permission a role/position preset gets. */
const overviewLayout = (key: Parameters<typeof FREIGHT_PERMS.overview.layout>[0]): string =>
FREIGHT_PERMS.overview.layout(key);
export type FreightSeedRole = {
key: string;
name: { en: string };
@@ -248,48 +253,55 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
{
key: "edr_line_staff",
name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
// OCC: the legacy role form of the control-centre desk (no position preset
// grants this layout — see EDR_FREIGHT_POSITIONS).
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff, overviewLayout("occ")],
},
{
key: "edr_operations_officer",
name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
permissionKeys: [
...ROLE_PERMISSION_PRESETS.operationsOfficer,
overviewLayout("operation"),
],
},
{
key: "edr_director",
name: { en: "EDR Director" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.director],
permissionKeys: [...ROLE_PERMISSION_PRESETS.director, overviewLayout("executive")],
},
{
key: "edr_ceo",
name: { en: "EDR CEO" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo],
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo, overviewLayout("executive")],
},
{
key: "edr_finance",
name: { en: "EDR Finance" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
// No position preset grants this layout — Finance only exists as a Role.
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance, overviewLayout("finance")],
},
{
key: "edr_marketing",
name: { en: "EDR Marketing" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing, overviewLayout("marketer")],
},
{
key: "edr_gl_ethiopia",
name: { en: "EDR Global Logistics — Ethiopia" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia],
permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia, overviewLayout("clearance")],
},
{
key: "edr_gl_djibouti",
name: { en: "EDR Global Logistics — Djibouti" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti],
permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti, overviewLayout("clearance")],
},
{
key: "edr_org_manager",
name: { en: "EDR Org Manager" },
permissionKeys: [
...BOOKING_RULE_ENGINE_PERMISSION_KEYS,
overviewLayout("executive"),
...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key),
...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key),
@@ -326,15 +338,17 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
* PositionPermission rows (NOT Role/RolePermission). Users get their access by
* being assigned to a Position via EmployeePosition.
*/
// No position preset grants the "occ" or "finance" overview layouts today —
// see the comments on edr_line_staff / edr_finance above.
export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
{ key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] },
{ key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] },
{ key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] },
{ key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] },
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] },
{ key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief, overviewLayout("executive")] },
{ key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director, overviewLayout("executive")] },
{ key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo, overviewLayout("executive")] },
{ key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl, overviewLayout("clearance")] },
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl, overviewLayout("clearance")] },
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer, overviewLayout("marketer")] },
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation, overviewLayout("operation")] },
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief, overviewLayout("operation")] },
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher, overviewLayout("operation")] },
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief, overviewLayout("operation")] },
];

View File

@@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
});
/**
* One entry per report definition (see modules/reports/definitions). Each
* gets its own permission, gated behind the `reports:view` master key that
* opens the Reports section itself.
* Keep new keys at the END: reportPermId derives ids from list index, so a
* mid-list insert would shift ids already seeded for later keys.
* Every report key ever seeded, in seed order.
*
* NEVER reorder or delete an entry: reportPermId derives a permission's uuid
* from its index here, so a shift would re-map ids already granted to roles.
* Retiring a report means adding it to RETIRED_REPORT_KEYS, not removing it.
* New keys go at the END.
*/
export const REPORT_KEYS = [
const SEEDED_REPORT_KEYS = [
"bookings-list",
"revenue-by-customer",
"aging-receivables",
@@ -98,20 +99,99 @@ export const REPORT_KEYS = [
"cargo-volume-by-station",
] as const;
export type ReportKey = (typeof REPORT_KEYS)[number];
/**
* Reports whose definition was deleted (see modules/reports/definitions) — a
* flat list the Exports module and its backoffice table already serve, or a
* narrower view of a report that supersedes it. Their permissions stay seeded
* so no live report's uuid moves; nothing resolves them to a definition.
*/
const RETIRED_REPORT_KEYS = [
"bookings-list",
"customer-status",
"contract-lifecycle",
"invoices-by-status",
"payments-by-status",
"revenue-summary",
] as const;
export const reportPermissionKey = (key: ReportKey): string =>
export type ReportKey = Exclude<
(typeof SEEDED_REPORT_KEYS)[number],
(typeof RETIRED_REPORT_KEYS)[number]
>;
/** One entry per live report definition — what the catalog and presets use. */
export const REPORT_KEYS: readonly ReportKey[] = SEEDED_REPORT_KEYS.filter(
(k): k is ReportKey =>
!(RETIRED_REPORT_KEYS as readonly string[]).includes(k),
);
export const reportPermissionKey = (key: string): string =>
`edr_freight_app:reports:${key.replace(/-/g, "_")}:view`;
const reportPermId = (index: number): string =>
`a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
const titleCase = (slug: string): string =>
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
slug
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map(
(key, index) =>
perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`),
// Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its
// index and its permission row, which is what stops the live ids from moving.
export const REPORT_PERMISSIONS: FreightPermissionSeed[] =
SEEDED_REPORT_KEYS.map((key, index) =>
perm(
reportPermId(index),
reportPermissionKey(key),
`Report: ${titleCase(key)}`,
),
);
/**
* Overview dashboard layouts (see the backoffice's role-dashboards.config.ts,
* where `LAYOUTS` renders one composition per key). Unlike reports, a caller
* lands on exactly ONE layout, so `OVERVIEW_LAYOUT_KEYS` is also the priority
* order: whoever resolves the permission set picks the FIRST key here the
* caller holds — the specific operational view wins over the broad executive
* one, same rule the old role/position-key table encoded.
*
* NEVER reorder — GET /overview/layouts and the frontend both walk this array
* to break ties, so reordering silently changes who gets which dashboard.
*/
export const OVERVIEW_LAYOUT_KEYS = [
"clearance",
"occ",
"operation",
"marketer",
"finance",
"executive",
] as const;
export type OverviewLayoutKey = (typeof OVERVIEW_LAYOUT_KEYS)[number];
export const OVERVIEW_LAYOUT_LABELS: Record<OverviewLayoutKey, string> = {
clearance: "Clearance & logistics dashboard",
occ: "Control centre dashboard",
operation: "Operations dashboard",
marketer: "Marketing dashboard",
finance: "Finance dashboard",
executive: "Executive dashboard",
};
export const overviewLayoutPermissionKey = (key: string): string =>
`edr_freight_app:overview:${key}:view`;
const overviewLayoutPermId = (index: number): string =>
`a4f00003-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
export const OVERVIEW_LAYOUT_PERMISSIONS: FreightPermissionSeed[] =
OVERVIEW_LAYOUT_KEYS.map((key, index) =>
perm(
overviewLayoutPermId(index),
overviewLayoutPermissionKey(key),
`Overview layout: ${OVERVIEW_LAYOUT_LABELS[key]}`,
),
);
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
@@ -572,8 +652,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'),
perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'),
perm(
"c9a00001-0001-4000-8000-000000000001",
"edr_freight_app:chat:view",
"Open internal chat",
),
perm(
"c9a00001-0001-4000-8000-000000000002",
"edr_freight_app:chat:sync",
"Re-run chat room/membership sync",
),
];
// D. Finance — payments + invoices
@@ -1381,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:train_scheduling:rules_manage",
"Manage global scheduling rules",
),
// Carved out of the coarse `update` — confirming a booking's cargo loaded/
// unloaded at a yard, across import, export, and intercity movements alike
// (the same schedules/:id/bookings/:bookingId/{load,unload} + intercity
// routes serve all three directions).
perm(
"a2a00001-0001-4000-8000-000000000006",
"edr_freight_app:train_scheduling:load",
"Confirm cargo loaded (import, export, intercity)",
),
perm(
"a2a00001-0001-4000-8000-000000000007",
"edr_freight_app:train_scheduling:unload",
"Confirm cargo unloaded (import, export, intercity)",
),
];
// L. Administration & settings (split from the coarse admin umbrella)
@@ -1746,6 +1848,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS,
...OVERVIEW_LAYOUT_PERMISSIONS,
...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS,
...CHAT_PERMISSIONS,
@@ -1907,6 +2010,15 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
/**
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
* coarse `update` so load/unload can be granted independently of general
* schedule editing. Covers import, export, and intercity alike: the
* generic per-booking route and the intercity-specific one both gate on
* these same two keys.
*/
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
@@ -1978,8 +2090,7 @@ export const FREIGHT_PERMS = {
// finance-level REQUEST grants (per action) and decision grants that apply
// to ANY pending request — including the holder's own.
/** Request recording an offline payment against a credit invoice. */
invoiceMarkPaid:
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
/** Request voiding a credit invoice (credits return to unbilled). */
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
/** Approve any pending invoice request (mark-paid or cancel). */
@@ -1988,8 +2099,8 @@ export const FREIGHT_PERMS = {
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
chat: {
view: 'edr_freight_app:chat:view',
sync: 'edr_freight_app:chat:sync',
view: "edr_freight_app:chat:view",
sync: "edr_freight_app:chat:sync",
},
payments: {
view: "edr_freight_app:payments:view",
@@ -2292,6 +2403,7 @@ export const FREIGHT_PERMS = {
},
overview: {
view: "edr_freight_app:overview:view",
layout: (key: OverviewLayoutKey): string => overviewLayoutPermissionKey(key),
},
reports: {
view: "edr_freight_app:reports:view",
@@ -2444,7 +2556,8 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.consignments.create,
];
const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k));
const allReportKeys = (): string[] =>
REPORT_KEYS.map((k) => reportPermissionKey(k));
// Everyone who works the booking desk also opens the overview dashboard and
// the canned reports — granted alongside bookings:view in every preset below.
@@ -2512,6 +2625,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
@@ -2732,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,

View File

@@ -229,7 +229,18 @@ export class FreightPositionsSeeder {
return;
}
await positionPermissionRepository.insert(rowsToInsert);
// orIgnore, not a bare insert: the read above and this write are not
// atomic across processes — two API replicas booting together (or a
// restart racing a running boot) both see the grant missing and both
// insert it, and the loser died on UQ_87ee8f7eef7366389a02ff69f04 with
// the whole seed transaction. ON CONFLICT DO NOTHING makes the grant
// idempotent no matter who else is inserting it.
await positionPermissionRepository
.createQueryBuilder()
.insert()
.values(rowsToInsert)
.orIgnore()
.execute();
this.logger.log(
`Granted ${rowsToInsert.length} permissions to position '${seed.key}'`,

View File

@@ -16,6 +16,7 @@ import {
Textarea,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Ban,
Download,
@@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
@@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
@@ -203,8 +205,14 @@ function ChargeCard({
{charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text>
)}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
@@ -214,6 +222,13 @@ function ChargeCard({
{meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</Group>
</Group>
{charge.file && (
@@ -297,12 +312,14 @@ function AddChargeModal({
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => void;
}) {
const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0;
@@ -311,11 +328,23 @@ function AddChargeModal({
setAmount("");
setCurrency("ETB");
setFile(null);
setDueDate(null);
};
const submit = (action: "draft" | "send") => {
if (!valid) return;
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
onSubmit({
reason: reason.trim(),
amount: Number(amount),
currency,
action,
file,
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
// roll the date back a day for evening local time in a positive-offset zone.
dueDate: dueDate
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
: null,
});
};
return (
@@ -355,6 +384,14 @@ function AddChargeModal({
w={100}
/>
</Group>
<DateInput
label="Due date"
placeholder="Defaults to 14 days after sending"
value={dueDate}
onChange={(v) => setDueDate(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button

View File

@@ -1,6 +1,3 @@
import type { AuthUser } from "@/auth/types";
import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey =
| "executive"
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
};
/**
* Position/role key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Roles are matched alongside positions because
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
* Priority order: a caller who holds more than one of the six
* `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
* here — the specific operational view wins over the broad executive one.
* Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
* bit for bit; keep the two in sync if this ever changes.
*/
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
["ethiopian_gl", "clearance"],
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
["djibouti_gl", "clearance"],
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
"clearance",
"occ",
"operation",
"marketer",
"finance",
"executive",
];
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
/**
* Which layout to render, given the keys `GET /overview/layouts` said the
* caller may see — the endpoint already filtered those by permission, so
* this only breaks the tie when a caller holds more than one. Same shape as
* the Reports page trusting `GET /reports`'s catalog rather than re-deriving
* access from permission keys client-side.
*
* Empty/unmapped falls back to the executive layout — same default the old
* role/position-key table used for superadmin, IAM admins, and any position
* that hasn't been granted one of these permissions yet.
*/
export function resolveOverviewLayout(
user: AuthUser | null | undefined,
allowed: OverviewLayoutKey[] | undefined,
): OverviewLayoutKey {
const held = new Set(getPositionKeys(user));
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
const held = new Set(allowed ?? []);
return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
}

View File

@@ -15,6 +15,8 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
@@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({
direction: string | null | undefined;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
@@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Tooltip
label={
canLoad
? "Train must be at the booking's origin yard"
: "You don't have permission to load cargo"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
@@ -393,13 +405,20 @@ export function IntercityRideAlongPanel({
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Tooltip
label={
canUnload
? "Train must be at the booking's destination yard"
: "You don't have permission to unload cargo"
}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}

View File

@@ -25,6 +25,8 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
@@ -111,6 +113,8 @@ export function LogPassYardWorkModal({
alreadyLogged: boolean;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
@@ -353,7 +357,9 @@ export function LogPassYardWorkModal({
{!row.loadedAt ? (
<Tooltip
label={
!logged
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
@@ -364,7 +370,7 @@ export function LogPassYardWorkModal({
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!logged || !row.canLoad}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}

View File

@@ -38,6 +38,8 @@ import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
@@ -170,6 +172,9 @@ export function ScheduleWorkspacePanel({
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
@@ -838,7 +843,9 @@ export function ScheduleWorkspacePanel({
{showLoad ? (
<Tooltip
label={
boardHere
!canLoad
? "You don't have permission to load cargo"
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
@@ -853,7 +860,7 @@ export function ScheduleWorkspacePanel({
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere}
disabled={!boardHere || !canLoad}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
@@ -869,7 +876,11 @@ export function ScheduleWorkspacePanel({
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
label={
canLoad
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
: "You don't have permission to load cargo"
}
withArrow
>
<Button
@@ -877,6 +888,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="blue"
radius="md"
disabled={!canLoad}
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() =>
@@ -894,7 +906,9 @@ export function ScheduleWorkspacePanel({
{showUnload ? (
<Tooltip
label={
alightHere
!canUnload
? "You don't have permission to unload cargo"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
}
@@ -905,7 +919,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="orange"
radius="md"
disabled={!alightHere}
disabled={!alightHere || !canUnload}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&

View File

@@ -51,6 +51,8 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
@@ -1566,6 +1568,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load);
const { data: rows = [], isLoading } = useQuery(
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
@@ -1655,16 +1659,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
<Button
size="compact-sm"
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
disabled={rows.length === 0}
disabled={rows.length === 0 || !canLoad}
onClick={() => setTrainPickerOpen(true)}
>
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
</Tooltip>
</Group>
<Modal
@@ -2305,6 +2311,8 @@ export function ImportArriveQueueTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload);
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
@@ -2489,12 +2497,13 @@ export function ImportArriveQueueTab({
>
Open
</Button>
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload} withArrow>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading || !canUnload}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
@@ -2506,6 +2515,7 @@ export function ImportArriveQueueTab({
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -235,6 +235,7 @@ export const QUERY_KEYS = {
OVERVIEW: {
ROOT: ["overview"] as const,
layouts: () => ["overview", "layouts"] as const,
dashboard: (range?: string) =>
["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) =>

View File

@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
OVERVIEW: {
BASE: "/overview",
LAYOUTS: "/overview/layouts",
BOOKINGS: "/overview/bookings",
CONTRACTS: "/overview/contracts",
BILLING: "/overview/billing",

View File

@@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") {
});
}
/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */
export function useOverviewLayouts() {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.layouts(),
queryFn: () => overviewService.getLayouts(),
staleTime: 5 * 60 * 1000,
});
}
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),

View File

@@ -104,6 +104,9 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:train_scheduling:view",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",

View File

@@ -4,7 +4,6 @@ import {
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
@@ -22,7 +21,7 @@ import {
ShieldOff,
Users,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -31,49 +30,22 @@ import {
ManualRegistrationBadge,
ProfileChips,
formatDate,
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import type { Company, CompanyListFilter } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
@@ -85,29 +57,110 @@ 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[] = [];
/**
* Every state a customer can be in, as one single-select list.
*
* Three of these are not `companies.status` values at all, which is why each
* option maps its own params:
* - **Pending approval** is submitted-and-awaiting-review, so it excludes
* drafts — a company row exists from the onboarding wizard's first click and
* would otherwise pad the review queue.
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
* - **Pending changes** is an already-approved (`active`) customer who edited
* their profile. `status` can never match them, so without this option their
* resubmission is only visible by opening their detail page.
*/
const STATUS_OPTIONS: {
value: string;
label: string;
params: Record<string, string>;
}[] = [
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
{ value: "active", label: "Active", params: { status: "active" } },
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
];
/**
* Filter pills. The review queues that used to sit beside them as segmented
* tabs are folded into the Status pill above — three of the five were never a
* plain `status` value, so as a separate tab strip they could contradict the
* status filter next to them. One list, mutually exclusive, no contradiction.
*/
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
toParams: (v) =>
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
},
{
key: "type",
label: "Type",
type: "enum",
multiple: false,
options: (
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
type: "enum",
multiple: false,
options: [
{ value: "commercial", label: "Commercial" },
{ value: "government", label: "Government" },
],
},
{
key: "nationality",
label: "Nationality",
type: "enum",
multiple: false,
options: [
{ value: "ethiopian", label: "Ethiopian" },
{ value: "foreign", label: "Foreign" },
],
},
{
key: "created",
label: "Registered",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
];
export default function CustomersPage() {
const navigate = useNavigate();
const [view, setView] = useState<CustomerView>("all");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
defaultSort: "review:DESC",
pageSize: 10,
});
const filter = useMemo(() => {
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
// `controls.params` is the whole query: page/pageSize/search, the split
// sortBy/sortOrder, and every pill's mapped params.
const filter = controls.params as unknown as CompanyListFilter;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants — hand the dialog the local day each bound falls on
* so the file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of ["createdFrom", "createdTo"]) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -293,33 +346,13 @@ export default function CustomersPage() {
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FilterBar
defs={NO_FILTER_DEFS}
defs={CUSTOMER_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);
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
<ExportButton datasetKey="customers" params={exportParams} />
</FilterBar>
</Box>
@@ -331,8 +364,8 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
controls.searchText
? "No companies match your search."
controls.activeCount > 0
? "No companies match these filters."
: "No companies yet."
}
error={

View File

@@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react";
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { PageContainer } from "@/components/page";
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
@@ -20,7 +19,7 @@ import {
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import { useOverview, useOverviewLayouts } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
@@ -57,13 +56,14 @@ function OverviewSkeleton() {
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data, isLoading, isError, error, refetch, isFetching } =
useOverview(range);
const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts();
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
// them is chosen by which overview:<key>:view permissions the caller holds
// (GET /overview/layouts already filtered these server-side).
const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key));
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
const accessDenied =
@@ -129,7 +129,7 @@ const OverviewPage = () => {
</Alert>
)}
{isLoading && !data ? (
{(isLoading || layoutsLoading) && !data ? (
<Stack mt="lg">
<OverviewSkeleton />
</Stack>

View File

@@ -224,6 +224,21 @@ const FleetResourcePage = () => {
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
// Wagons-only: "last maintenance" is a derived value (latest status-log
// flip to MAINTENANCE), not a column other fleet resources have.
const dateDefs: FilterDef[] =
slug === "wagons"
? [
dateDef,
{
key: "lastMaintenance",
label: "Last maintenance",
type: "date",
secondary: true,
toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"),
},
]
: [dateDef];
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
@@ -235,13 +250,13 @@ const FleetResourcePage = () => {
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
...dateDefs,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs]
: dateDefs;
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });

View File

@@ -1,29 +1,127 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Freight } from "@edr/types";
import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Invoice, InvoiceListFilter } from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
value,
label: humanize(value),
}));
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
value,
label: humanize(value),
}));
/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */
const EIMS_STATUS_OPTIONS = [
"NOT_SUBMITTED",
"SUBMITTING",
"REGISTERED",
"FAILED",
"UNKNOWN",
"CANCELLED",
].map((value) => ({ value, label: humanize(value) }));
/**
* Every dimension the list narrows by. Keys are the URL keys; `toParams` maps
* them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More
* filters" until they hold a value, then pin themselves as a pill.
*/
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
{
key: "currency",
label: "Currency",
type: "enum",
multiple: false,
options: [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
],
},
{
// One pill for the two settlement cuts Finance actually chases. Both are
// computed from the balance and due date rather than read off `status` —
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
key: "settlement",
label: "Settlement",
type: "enum",
multiple: false,
options: [
{ value: "outstanding", label: "Outstanding" },
{ value: "overdue", label: "Overdue" },
],
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "issued",
label: "Issued",
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams("issuedFrom", "issuedTo"),
},
{
key: "due",
label: "Due",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("dueFrom", "dueTo"),
},
{
key: "amount",
label: "Amount",
type: "number",
secondary: true,
operators: ["between", "is"],
// Amounts are compared in each invoice's OWN currency — pair this with the
// currency pill when the mix matters.
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
{
key: "eimsStatuses",
label: "EIMS",
type: "enum",
secondary: true,
options: EIMS_STATUS_OPTIONS,
},
];
const SORT_OPTIONS = [
{ value: "issuedAt:DESC", label: "Newest issued" },
{ value: "issuedAt:ASC", label: "Oldest issued" },
{ value: "dueAt:ASC", label: "Due soonest" },
{ value: "totalAmount:DESC", label: "Largest amount" },
{ value: "balanceAmount:DESC", label: "Largest balance" },
{ value: "invoiceNumber:ASC", label: "Invoice no. (AZ)" },
];
/** Date params the export's `daterange` coercion expects as calendar days. */
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
/**
* Which record raised the invoice, not just which subsystem. The source label
@@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const controls = useFilters(INVOICE_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
});
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const filter = controls.params as unknown as InvoiceListFilter;
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
@@ -86,7 +176,6 @@ export default function InvoicesPanel() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
@@ -106,14 +195,28 @@ export default function InvoicesPanel() {
);
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
// the current filters, not just the visible page. Same params minus
// pagination, so the card can never total a different set than the table.
const summaryFilter = useMemo(() => {
const { page: _page, pageSize: _pageSize, ...rest } = filter;
return rest;
}, [filter]);
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }),
);
/**
* The export's `daterange` filters are coerced from calendar days, while the
* list takes ISO instants — hand the dialog the local day each bound falls
* on so an exported file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
@@ -236,45 +339,14 @@ export default function InvoicesPanel() {
<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 invoice, customer, booking ref, GRN or shipping line…"
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("")}
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId="invoices"
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
<ActionIcon
variant="default"
size="lg"
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -296,7 +368,9 @@ export default function InvoicesPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery ? "No invoices match your search." : "No invoices yet."
controls.activeCount > 0
? "No invoices match these filters."
: "No invoices yet."
}
error={
isError
@@ -306,18 +380,7 @@ export default function InvoicesPanel() {
}
: 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

@@ -151,6 +151,12 @@ const TRADE_DIRECTIONS = [
* a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair.
*
* Drift is no longer silent: `OperationsTargetsService.assertDimensionKey`
* rejects any key outside the API's own vocabulary, so a stale entry here
* surfaces as a 400 on save rather than a plan that quietly never joins.
* `UNCLASSIFIED` is left out deliberately — the API accepts it, but there is no
* sense in planning against cargo nobody has classified.
*/
export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
@@ -713,14 +719,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
},
{
// Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity.
// Commit the number at whatever grain the business quotes it — the
// report re-gathers it into whichever grain the viewer asks for.
name: "periodType",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Daily", value: "day" },
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" },
{ label: "Half-yearly", value: "half_year" },
{ label: "Nine-monthly", value: "nine_month" },
{ label: "90-day", value: "ninety_day" },
{ label: "Yearly", value: "year" },
],
},

View File

@@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
@@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => {
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const refresh = () =>
queryClient.invalidateQueries({
@@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
</Tooltip>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload}>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardList,
Filter,
PackageCheck,
PackageOpen,
PackagePlus,
@@ -17,7 +18,6 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import {
AccrualDashboard,
CycleTimeCard,
@@ -44,44 +44,42 @@ interface Metric {
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: string;
}
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
// null → the API defaults `received` to "today", matching the page's original behaviour.
const [receivedDate, setReceivedDate] = useState<string | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const [filtersOpen, setFiltersOpen] = useState(false);
const hasCustomDate = Boolean(receivedDate);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
// Same date both ends → the one day the picker selected, inclusive.
dateFrom: receivedDate ?? undefined,
dateTo: receivedDate ?? undefined,
warehouseId: warehouseId ?? undefined,
});
@@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() {
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
subtitle="Freight import/export logistics operations overview"
action={
<Group gap="sm" wrap="wrap" justify="flex-end">
<DatePickerInput
placeholder="Received: today"
value={receivedDate}
onChange={setReceivedDate}
clearable
w={180}
/>
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
<Popover.Target>
<Button
variant="default"
leftSection={<Filter size={16} />}
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={240}>
<Select
label="Warehouse"
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput
type="range"
placeholder="Received: today"
value={dateRange}
onChange={setDateRange}
presets={getDateRangePresets()}
clearable
w={230}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
{activeFilterCount > 0 && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setWarehouseId(null);
setReceivedDate(null);
}}
/>
}
>
Live · updates every 60s
</Badge>
Clear filters
</Button>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
/>
{(warehouseId || hasCustomRange) && (
{(warehouseId || hasCustomDate) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
{hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date filter.
</Text>
)}
@@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : (
<Stack gap="xl">
<Stack gap="lg">
{/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm">
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
padding="md"
withBorder
radius="md"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
{metric.icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted" fw={600}>
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
</Text>
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
{data ? data[metric.key] : 0}
</Text>
</Stack>
</Group>
</Card>
))}

View File

@@ -583,6 +583,8 @@ export const bookingsService = {
currency: string;
action: "draft" | "send";
file?: File | null;
/** ISO date (YYYY-MM-DD); omit to fall back to the invoice's default 14-day term. */
dueDate?: string | null;
},
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
@@ -590,6 +592,7 @@ export const bookingsService = {
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.dueDate) form.append("dueDate", payload.dueDate);
if (payload.file) form.append("file", payload.file);
const response = await client.post(
`/bookings/${id}/additional-charges`,

View File

@@ -1,6 +1,7 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config";
import type {
IOverviewBillingTab,
IOverviewBookingsTab,
@@ -16,7 +17,19 @@ import type {
const O = URL_CONSTANTS.OVERVIEW;
/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */
export interface IOverviewLayoutOption {
key: OverviewLayoutKey;
label: string;
}
export const overviewService = {
/** Layouts the caller has permission to render, in server priority order. */
getLayouts: async (): Promise<IOverviewLayoutOption[]> => {
const response = await client.get<IOverviewLayoutOption[]>(O.LAYOUTS);
return unwrap(response);
},
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
const response = await client.get<IOverviewDashboard>(O.BASE, {
params: range ? { range } : undefined,

View File

@@ -51,6 +51,10 @@ export interface WagonListFilters {
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Last-maintenance day range (YYYY-MM-DD), both ends inclusive — matches
* the latest status-log flip to MAINTENANCE, not a stored column. */
maintenanceFrom?: string;
maintenanceTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
@@ -67,6 +71,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();

View File

@@ -313,6 +313,10 @@ export interface CompanyListFilter {
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
/**

View File

@@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice {
sourceRef?: InvoiceSourceRef | null;
}
/** Query parameters for the invoice list. */
/**
* Query parameters for the invoice list. Every key maps 1:1 onto
* `FilterInvoiceDto` on the API — the list endpoint runs with
* `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a
* silently ignored extra.
*/
export interface InvoiceListFilter {
page: number;
pageSize: number;
companyId?: string;
/** Single status — kept for the worklists that pin one. */
status?: Freight.InvoiceStatus;
/** CSV multi-select status, as the filter bar sends it. */
statuses?: string;
/** CSV of `Freight.InvoiceSource` values. */
sources?: string;
/** CSV of EIMS filing states. */
eimsStatuses?: string;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
/** Outstanding balance only. */
hasBalance?: boolean;
/** Outstanding AND past due — computed, not read off `status`. */
overdue?: boolean;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */

View File

@@ -128,6 +128,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/api/bookings/${id}/carriage-acceptance-sheet`,
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,

View File

@@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
<Text fz="12px" c="#9AA8B5" mt={2}>
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
{charge.convertedAmount != null
? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})`
: ""}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
{charge.dueAt && charge.status === "SENT" && (
<Text fz="12px" c="#9AA8B5">
Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</Text>
)}
</Box>
<Badge
radius="sm"

View File

@@ -28,6 +28,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -251,6 +252,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
queryFn: () => warehouseService.bookingHandovers(booking.id),
});
const { data: emptyReturns = [] } = useQuery({
queryKey: ["emptyContainerReturns", booking.id],
queryFn: () =>
bookingsService.listEmptyContainerReturns(booking.id).catch(() => []),
});
const [downloadingReturnId, setDownloadingReturnId] = useState<string | null>(null);
const downloadEir = async (ret: EmptyContainerReturn) => {
setDownloadingReturnId(ret.id);
try {
const blob = await bookingsService.downloadEquipmentInterchangeDocument(ret.id);
saveBlob(blob, `equipment-interchange-${ret.containerNumber}.pdf`);
} catch {
toast.error("Could not download the interchange receipt.");
} finally {
setDownloadingReturnId(null);
}
};
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
@@ -303,6 +322,14 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
},
];
// Carriage acceptance sheet only exists for export bookings — 404s
// (skipped below) for import/domestic, so this is safe unconditionally.
if (booking.tradeDirection === "EXPORT") {
jobs.push({
name: `carriage-acceptance-${ref}.pdf`,
fn: () => bookingsService.downloadCarriageAcceptanceSheet(booking.id),
});
}
let saved = 0;
for (const job of jobs) {
try {
@@ -584,12 +611,56 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── 4b. Equipment interchange receipts (empty container returns) ─── */}
{emptyReturns.length > 0 && (
<SectionCard>
<CardTitle>Equipment interchange receipts</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Container number, size, return time, depot, and condition for each empty
container returned on this booking.
</Text>
<Stack gap={0}>
{emptyReturns.map((ret, i) => (
<Box
key={ret.id}
py={12}
style={{
borderBottom:
i === emptyReturns.length - 1 ? undefined : "1px solid #F2F5F8",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box miw={0} flex={1}>
<Text fz="13.5px" fw={700} c="#10202F">
{ret.containerNumber}
{ret.containerSize ? ` · ${ret.containerSize}ft` : ""}
</Text>
<Text fz="12px" c="#9AA8B5">
{ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"}
{ret.facility ? ` · ${ret.facility}` : ""}
{ret.condition ? ` · ${ret.condition}` : ""}
</Text>
</Box>
<IconSquare
icon={<Download size={15} />}
onClick={
downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret)
}
/>
</Group>
</Box>
))}
</Stack>
</SectionCard>
)}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Goods Received Note, gate clearance / release order and handover download all
available documents for this booking in one click.
Goods Received Note, gate clearance / release order, handover, and for export
bookings the carriage acceptance sheet: download all available documents for
this booking in one click.
</Text>
<Button
leftSection={<Download size={16} />}

View File

@@ -7,6 +7,19 @@ import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
export interface EmptyContainerReturn {
id: string;
containerNumber: string;
containerSize: "20" | "40" | null;
returnDate: string;
facility: string | null;
yard: string | null;
zone: string | null;
condition: string | null;
status: string;
returnedBy: "EDR" | "CUSTOMER" | null;
}
export interface MileVehicleSummary {
plate: string | null;
code: string | null;
@@ -385,6 +398,19 @@ export const bookingsService = {
);
return data.data ?? data;
},
listEmptyContainerReturns: async (bookingId: string): Promise<EmptyContainerReturn[]> => {
const { data } = await client.get(
`/api/import-operations/bookings/${bookingId}/empty-container-returns`,
);
return data.data ?? data;
},
downloadEquipmentInterchangeDocument: async (returnId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/import-operations/empty-container-returns/${returnId}/document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
@@ -599,6 +625,13 @@ export const bookingsService = {
return data;
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",
});
return data;
},
checkPayment: async (orderId: string): Promise<{ status: string }> => {
const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`);
return data.data ?? data;

View File

@@ -8,6 +8,10 @@ export class LogExcessBaggageDto {
@IsOptional() @IsString() bookingReference?: string;
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
@IsOptional() @IsString() agentId?: string;
@ApiPropertyOptional({ example: '+251911223344', description: 'Override the phone the payment link SMS should go to. Defaults to the booking contact phone.' })
@IsOptional() @IsString() contactPhone?: string;
@ApiPropertyOptional({ example: 'passenger@example.com', description: 'Override the email the payment link should also be sent to. Defaults to the booking contact email.' })
@IsOptional() @IsString() contactEmail?: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })

View File

@@ -115,8 +115,8 @@ export class ExcessBaggageService {
const totalMinor = feePerKgMinor * dto.excessWeightKg;
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
const paidAt = dto.collectCash ? new Date() : null;

View File

@@ -229,10 +229,11 @@ export class FleetController {
}
@Get('coaches/utilization')
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
@ApiResponse({ status: 200, description: 'Coach utilization data' })
getCoachUtilization() {
return this.service.getCoachUtilization();
getCoachUtilization(@Query('scheduleId') scheduleId?: string) {
return this.service.getCoachUtilization(scheduleId);
}
@Get('coaches/:id')

View File

@@ -822,12 +822,35 @@ export class FleetService {
};
}
async getCoachUtilization() {
async getCoachUtilization(scheduleId?: string) {
const where = scheduleId ? { scheduleId } : {};
const coaches = await this.prisma.coach.findMany({
where: scheduleId
? {
assignments: {
some: { scheduleId },
},
}
: {},
include: {
coachType: true,
seats: { select: { id: true, status: true } },
seats: {
select: {
id: true,
status: true,
bookingSeats: {
where,
select: { id: true },
},
blocks: {
where,
select: { id: true, reasonCategory: true },
},
},
},
assignments: {
where,
include: {
schedule: {
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
@@ -842,10 +865,12 @@ export class FleetService {
return coaches.map((coach) => {
const totalSeats = coach.seats.length;
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
const availableSeats = scheduleId
? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0)
: coach.seats.filter((s) => s.status === 'AVAILABLE').length;
const totalAssignments = coach.assignments.length;
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;

View File

@@ -49,6 +49,8 @@ class CreateSupplementaryChargeDto {
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
@ApiPropertyOptional({ description: 'Override the phone the payment link SMS should go to. Falls back to the booking contact phone.', example: '+251911223344' }) @IsOptional() @IsString() contactPhone?: string;
@ApiPropertyOptional({ description: 'Override the email the payment link should also be sent to. Falls back to the booking contact email.', example: 'passenger@example.com' }) @IsOptional() @IsString() contactEmail?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -52,6 +52,8 @@ export class SupplementaryChargesService {
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
createdBy: string;
}) {
const booking = await this.prisma.booking.findUnique({
@@ -76,8 +78,8 @@ export class SupplementaryChargesService {
},
});
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
await this.sendLink(charge, booking.bookingRef, phone, email);
await this.auditService.log({

View File

@@ -200,7 +200,7 @@ export class SchedulesService {
liveStatus: { select: { delayMinutes: true } },
_count: { select: { coachAssignments: true, bookings: true } },
},
orderBy: { departureAt: 'asc' },
orderBy: { departureAt: 'desc' },
});
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
type Tab = 'types' | 'coaches' | 'utilization';
type Tab = 'types' | 'coaches';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
@@ -152,8 +152,6 @@ function CoachesPageContent() {
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const queryClient = useQueryClient();
@@ -169,12 +167,6 @@ function CoachesPageContent() {
queryFn: () => fleetApi.getCoaches({}),
});
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
queryKey: ['coach-utilization'],
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
enabled: activeTab === 'utilization',
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
@@ -531,16 +523,6 @@ function CoachesPageContent() {
>
Coaches
</button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div>
{/* Coach Types Tab */}
@@ -591,105 +573,6 @@ function CoachesPageContent() {
</div>
)}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
const UTIL_COLS = [
{ key: 'number', label: 'Coach' },
{ key: 'coachType', label: 'Type' },
{ key: 'totalSeats', label: 'Total Seats' },
{ key: 'availableSeats', label: 'Available' },
{ key: 'bookedSeats', label: 'Booked' },
{ key: 'blockedSeats', label: 'Blocked' },
{ key: 'maintenanceSeats', label: 'Maintenance' },
{ key: 'utilizationRate', label: 'Utilization %' },
{ key: 'totalAssignments', label: 'Assignments' },
{ key: 'totalBookings', label: 'Total Bookings' },
];
const doExport = () => {
if (!rows.length) { alert('No data to export'); return; }
const headers = UTIL_COLS.map(c => c.label);
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
const dateStr = new Date().toISOString().split('T')[0];
if (exportUtilFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportUtilFormat === 'excel') {
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
}
setExportUtilModalOpen(false);
};
return (
<div className="pt-6 space-y-4">
<div className="flex justify-end">
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
</div>
<DataTable
columns={[
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={doExport}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
})()}
</div>
{/* Delete Confirmation */}

View File

@@ -1,13 +1,13 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi, apiClient } from '@/lib/api';
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -28,7 +28,7 @@ export default function ExcessBaggagePage() {
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const [logModal, setLogModal] = useState(false);
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false });
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
const [logError, setLogError] = useState<string | null>(null);
const [resendModal, setResendModal] = useState<any>(null);
const [resendSuccess, setResendSuccess] = useState(false);
@@ -54,12 +54,36 @@ export default function ExcessBaggagePage() {
}),
});
useEffect(() => {
if (!logModal) return;
const bookingRef = logForm.bookingReference.trim();
if (!bookingRef) {
setLogForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures: the agent can still override the number manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [logForm.bookingReference, logModal]);
const logMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setLogModal(false);
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false });
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
setLogError(null);
},
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
@@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
Log Excess Luggage
</ActionButton>
</div>
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={logForm.paymentPhone}
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
The payment link will be sent to the phone above and the booking's saved email when present.
</p>
)}
</>
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
bookingReference: logForm.bookingReference.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
contactPhone: logForm.paymentPhone.trim() || undefined,
});
}}
>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { bookingsApi } from '@/lib/api';
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
@@ -14,13 +15,37 @@ interface Props {
}
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const bookingRef = form.bookingRef.trim();
if (!bookingRef) {
setForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 300);
return () => clearTimeout(timeout);
}, [form.bookingRef, isOpen]);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess('Charge created and payment link sent.');
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
});
@@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
await createMutation.mutateAsync({
bookingRef: form.bookingRef.trim(),
amountMinor,
reason: form.reason,
notes: form.notes || undefined,
contactPhone: form.paymentPhone.trim() || undefined,
});
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
@@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">

View File

@@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?:
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
mutationFn: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();

View File

@@ -0,0 +1,362 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, BarChart3, Download, Search } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
interface ScheduleOption {
id: string;
label: string;
}
interface CoachUtilizationRow {
id: string;
number: string;
coachType: string | null;
status: string | null;
totalSeats: number;
availableSeats: number;
bookedSeats: number;
blockedSeats: number;
maintenanceSeats: number;
utilizationRate: number;
totalAssignments: number;
totalBookings: number;
}
export default function CoachUtilizationReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState("");
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: () => apiClient.get("/reports/schedules?all=true"),
});
const { data, isLoading, isError } = useQuery<CoachUtilizationRow[]>({
queryKey: ["coach-utilization-report", scheduleId],
queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const schedules = schedulesRaw ?? [];
const rows = data ?? [];
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) => {
const coachType = row.coachType ?? "";
const status = row.status ?? "";
return (
row.number.toLowerCase().includes(q) ||
coachType.toLowerCase().includes(q) ||
status.toLowerCase().includes(q)
);
});
}, [rows, search]);
const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25);
const summary = useMemo(() => {
if (!rows.length) return null;
const totals = rows.reduce(
(acc, row) => {
acc.totalSeats += row.totalSeats;
acc.availableSeats += row.availableSeats;
acc.bookedSeats += row.bookedSeats;
acc.blockedSeats += row.blockedSeats;
acc.maintenanceSeats += row.maintenanceSeats;
acc.totalBookings += row.totalBookings;
acc.totalAssignments += row.totalAssignments;
return acc;
},
{
totalSeats: 0,
availableSeats: 0,
bookedSeats: 0,
blockedSeats: 0,
maintenanceSeats: 0,
totalBookings: 0,
totalAssignments: 0,
},
);
const avgUtilization = rows.length
? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length
: 0;
return {
totalCoaches: rows.length,
totalSeats: totals.totalSeats,
availableSeats: totals.availableSeats,
bookedSeats: totals.bookedSeats,
blockedSeats: totals.blockedSeats,
maintenanceSeats: totals.maintenanceSeats,
avgUtilization,
totalAssignments: totals.totalAssignments,
totalBookings: totals.totalBookings,
};
}, [rows]);
const doExport = () => {
if (!filteredRows.length) return;
const headers = [
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization %",
"Assignments",
"Total Bookings",
];
const rowsCsv = filteredRows.map((row) => [
row.number,
row.coachType ?? "—",
row.status ?? "—",
String(row.totalSeats),
String(row.availableSeats),
String(row.bookedSeats),
String(row.blockedSeats),
String(row.maintenanceSeats),
`${row.utilizationRate}%`,
String(row.totalAssignments),
String(row.totalBookings),
]);
const csv = [
headers.map((header) => `"${header}"`).join(","),
...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")),
].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Utilization Report</h1>
<p className="text-muted-foreground mt-1">
Occupancy, availability, and booking load by coach for a selected schedule.
</p>
</div>
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={(e) => {
setScheduleId(e.target.value);
setSearch("");
reset();
}}
disabled={loadingSchedules}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.label}
</option>
))}
</select>
</div>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load coach utilization.</p>}
</div>
{!scheduleId && (
<div className="card py-16 text-center text-muted-foreground">
<Activity className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-base font-medium text-foreground">Choose a schedule to review coach occupancy</p>
<p className="text-xs mt-1">The report drills into availability, bookings, and maintenance status for each assigned coach.</p>
</div>
)}
{data && summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4">
<div className="card">
<p className="text-muted-foreground text-sm font-medium">Coaches</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalCoaches}</p>
<p className="text-xs text-muted-foreground mt-1">Assigned coaches</p>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Seats</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Across all coaches</p>
</div>
<BarChart3 className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Booked</p>
<p className="text-2xl font-bold mt-2 text-rose-600 dark:text-rose-400">{summary.bookedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Occupied seats</p>
</div>
<Activity className="h-8 w-8 text-rose-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Available</p>
<p className="text-2xl font-bold mt-2 text-emerald-600 dark:text-emerald-400">{summary.availableSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Open seats</p>
</div>
<Activity className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">{summary.blockedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Unavailable seats</p>
</div>
<Activity className="h-8 w-8 text-slate-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg Utilization</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{summary.avgUtilization.toFixed(1)}%
</p>
<p className="text-xs text-muted-foreground mt-1">Across coach set</p>
</div>
<BarChart3 className="h-8 w-8 text-amber-500 opacity-30" />
</div>
</div>
</div>
<div className="card p-0">
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Coach Details
</h3>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
className="input max-w-xs pl-10"
placeholder="Coach, type, or status…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
reset();
}}
/>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filteredRows.length}>
Export CSV
</ActionButton>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization",
"Assignments",
"Bookings",
].map((header) => (
<th
key={header}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{paged.map((row) => (
<tr key={row.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 font-medium whitespace-nowrap">{row.number}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.coachType ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.status ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-emerald-600 dark:text-emerald-400">{row.availableSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-rose-600 dark:text-rose-400">{row.bookedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-600 dark:text-slate-400">{row.blockedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-amber-600 dark:text-amber-400">{row.maintenanceSeats}</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${Math.min(row.utilizationRate, 100)}%` }} />
</div>
<span className="font-mono text-sm">{row.utilizationRate.toFixed(1)}%</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalAssignments}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalBookings}</td>
</tr>
))}
{paged.length === 0 && (
<tr>
<td colSpan={11} className="py-8 text-center text-sm text-muted-foreground">
No coach utilization rows found
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
</>
)}
{!data && !isLoading && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">
No utilization data found for this schedule.
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react';
import { Download } from 'lucide-react';
@@ -39,6 +39,7 @@ export default function TicketsPage() {
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
@@ -163,10 +164,35 @@ export default function TicketsPage() {
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
useEffect(() => {
if (!excessModalOpen || !excessTicket) return;
const bookingRef = excessTicket?.booking?.bookingRef ?? '';
if (!bookingRef) {
setExcessPaymentPhone('');
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setExcessPaymentPhone((prev) => prev || nextPhone);
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [excessModalOpen, excessTicket]);
const openExcessModal = (ticket: any) => {
setExcessTicket(ticket);
setExcessKg('');
setExcessCollectCash(false);
setExcessPaymentPhone('');
setExcessError(null);
setExcessResult(null);
setExcessModalOpen(true);
@@ -179,6 +205,7 @@ export default function TicketsPage() {
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
contactPhone: excessPaymentPhone.trim() || undefined,
});
};
@@ -979,6 +1006,15 @@ export default function TicketsPage() {
required
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={excessPaymentPhone}
onChange={(e) => setExcessPaymentPhone(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -200,8 +200,14 @@ export const paymentsApi = {
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
create: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)

View File

@@ -659,8 +659,13 @@ export default function ConfirmationPage() {
{(() => {
// The server-confirmed settled amount is authoritative — prefer it over
// any client-side session state, which can go stale (e.g. after a refresh).
// Despite its name, PaymentIntent.amountMinor holds MAJOR units — it is the
// charge amount produced by currencyService.*ToChargeMajor (see initiate() in
// payments.service.ts; reports.service.ts multiplies it by 100 to get real
// minor units). Do NOT divide by 100 here — the voucher does the same via
// fareIsMajorUnits.
if (_booking?.payment?.amountMinor != null) {
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
return `${_booking.payment.currency || 'ETB'} ${_booking.payment.amountMinor.toFixed(2)}`;
}
if (reviewedTotalMinor != null)
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;

View File

@@ -909,6 +909,11 @@ export interface AdditionalCharge {
status: AdditionalChargeStatus;
amount: number;
currency: string;
/** Amount converted to the other of ETB/USD at the current exchange rate; null if the rate feed is down. */
convertedAmount: number | null;
convertedCurrency: string | null;
/** Optional payment due date finance sets on the charge; falls back to the invoice's own default term when unset. */
dueAt: string | null;
file: { id: string; name: string; url: string } | null;
invoiceId: string | null;
invoiceNumber: string | null;