mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
BIN
EDR-Freight-User-Guide.pdf
Normal file
BIN
EDR-Freight-User-Guide.pdf
Normal file
Binary file not shown.
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)`;
|
||||
@@ -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; " +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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}%` });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (Oct–Dec
|
||||
* and the 5–6 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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
// Oct–Dec 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: Jan–Sep, then a short Oct–Dec.
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 Jan–Sep plus
|
||||
* a short Oct–Dec. 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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")] },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}'`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
OVERVIEW: {
|
||||
BASE: "/overview",
|
||||
LAYOUTS: "/overview/layouts",
|
||||
BOOKINGS: "/overview/bookings",
|
||||
CONTRACTS: "/overview/contracts",
|
||||
BILLING: "/overview/billing",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (Z–A)" },
|
||||
] 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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. (A–Z)" },
|
||||
];
|
||||
|
||||
/** 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 maker–checker 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}
|
||||
/>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -200,7 +200,7 @@ export class SchedulesService {
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
orderBy: { departureAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user