Merge pull request #1329 from Tria-plc/freight_feature/usermanagement

feat(freight): offer built-train wagons per boarding yard on multi-ya…
This commit is contained in:
marshal
2026-08-18 11:28:32 +03:00
committed by GitHub
33 changed files with 1234 additions and 231 deletions

View File

@@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
@@ -218,6 +219,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule, FileUploadSettingsModule,
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
PaymentSettingsModule,
StampSettingsModule, StampSettingsModule,
LogoSettingsModule, LogoSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table controlling whether Finance may settle invoices by hand,
* per currency (see ManualPaymentSettingsService). Defaults preserve the
* pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual
* settlement is the new capability and must be switched on deliberately (OFF).
*/
export class ManualPaymentSettings3560000000000 implements MigrationInterface {
name = "ManualPaymentSettings3560000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.manual_payment_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
etb_enabled boolean NOT NULL DEFAULT false,
usd_enabled boolean NOT NULL DEFAULT true,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled)
SELECT false, true
WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.manual_payment_settings;`,
);
}
}

View File

@@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config { get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
}); });
@@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => {
{} as never, {} as never,
{} as never, {} as never,
{ get: () => undefined } as never, { get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, manager, savedLines }; return { service, manager, savedLines };
} }
@@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config { get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config { get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config { get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config { get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config {} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, defaultManager, txManager, transaction }; return { service, defaultManager, txManager, transaction };
}; };
@@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config {} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, manager }; return { service, manager };
}; };
@@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config {} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, repo }; return { service, repo };
}; };
@@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config {} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, repo }; return { service, repo };
}; };
@@ -943,6 +953,7 @@ describe("BillingService.document", () => {
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined, : undefined,
} as never, // config } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
); );
return { service, render, renderThermal }; return { service, render, renderThermal };
}; };

View File

@@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity";
// payers straight off the table. // payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types"; import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -201,6 +202,7 @@ export class BillingService {
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService, private readonly files: FilesService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
) { } ) { }
// ── Reads ────────────────────────────────────────────────────────────────── // ── Reads ──────────────────────────────────────────────────────────────────
@@ -370,20 +372,24 @@ export class BillingService {
const pageSize = const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
// Only currencies whose manual-payment channel is switched on are listed:
// a row Finance cannot act on is noise, and the confirm endpoint would
// refuse it anyway. All off → nothing to work.
const enabled = await this.manualPaymentSettings.enabledCurrencies();
if (!enabled.length) return { items: [], total: 0 };
const currencies = filter.currency
? enabled.filter((c) => c === filter.currency)
: enabled;
if (!currencies.length) return { items: [], total: 0 };
const qb = this.dataSource const qb = this.dataSource
.getRepository(Invoice) .getRepository(Invoice)
.createQueryBuilder("invoice") .createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company") .leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) IN ('USD', 'ETB')") .where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
.orderBy("invoice.issuedAt", "DESC") .orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize); .take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) { if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status }); qb.andWhere("invoice.status = :status", { status: filter.status });
} else { } else {
@@ -465,7 +471,8 @@ export class BillingService {
/** /**
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* or counter payment: stores the slip against the invoice and settles the * or counter payment. Refused when that currency's manual-payment channel is
* switched off in settings. Stores the slip against the invoice and settles the
* FULL outstanding balance through * FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings) * {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so * emits `booking.invoice.paid` — the same event an online payment fires, so
@@ -485,6 +492,13 @@ export class BillingService {
): Promise<Invoice> { ): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId); const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
// The channel is a setting, not a role: even a permitted user cannot
// settle by hand in a currency whose channel is switched off.
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
throw new BadRequestException(
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
);
}
if (!file) { if (!file) {
throw new BadRequestException("The bank payment slip file is required."); throw new BadRequestException("The bank payment slip file is required.");
} }

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional } from "class-validator";
/**
* Partial update: the UI flips one currency at a time, so an omitted field
* leaves that currency's channel exactly as it was.
*/
export class UpdateManualPaymentSettingDto {
@ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" })
@IsOptional()
@IsBoolean()
etbEnabled?: boolean;
@ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" })
@IsOptional()
@IsBoolean()
usdEnabled?: boolean;
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* Single-row table controlling whether Finance may settle invoices by hand
* (bank transfer / counter payment) instead of the customer paying online.
*
* Per currency on purpose: the two channels are operationally different — USD
* bookings have always been bank-transfer-only, while ETB normally goes
* through the gateway and manual settlement is the exception. Switching one
* off must not switch off the other.
*/
@Entity({ schema: "freight", name: "manual_payment_settings" })
export class ManualPaymentSetting extends BaseEntity {
/** Manual settlement allowed for ETB invoices. */
@Column({ name: "etb_enabled", type: "boolean", default: false })
etbEnabled!: boolean;
/** Manual settlement allowed for USD invoices. */
@Column({ name: "usd_enabled", type: "boolean", default: true })
usdEnabled!: boolean;
/** IAM user id of the last operator to change either toggle. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,47 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
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 { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
@ApiTags("payment-settings")
@ApiBearerAuth()
@Controller("payment-settings/manual")
export class ManualPaymentSettingsController {
constructor(private readonly service: ManualPaymentSettingsService) {}
/**
* Read is gated on `manual_payment:view`, which Finance also holds — the
* Manual Payments worklist reads this to know which currency tabs to offer.
*/
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.view,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Whether manual (offline) invoice settlement is enabled, per currency",
})
get() {
return this.service.get();
}
@Patch()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
})
update(
@Body() dto: UpdateManualPaymentSettingDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.update(dto, user?.id ?? null);
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** The two currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD";
/**
* Owns the single `manual_payment_settings` row: whether Finance may settle
* invoices by hand, per currency.
*
* Defaults mirror how the platform behaved before the toggles existed — USD
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
* the new capability and starts OFF, so enabling it is a deliberate act.
*/
@Injectable()
export class ManualPaymentSettingsService {
private readonly logger = new Logger(ManualPaymentSettingsService.name);
constructor(
@InjectRepository(ManualPaymentSetting)
private readonly repository: Repository<ManualPaymentSetting>,
) {}
/** The settings row, created at the defaults on first access. */
async get(): Promise<ManualPaymentSetting> {
const existing = await this.repository.findOne({ where: {} });
if (existing) return existing;
return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true }),
);
}
/** Currencies manual settlement is currently allowed for. */
async enabledCurrencies(): Promise<ManualPaymentCurrency[]> {
const setting = await this.get();
const enabled: ManualPaymentCurrency[] = [];
if (setting.etbEnabled) enabled.push("ETB");
if (setting.usdEnabled) enabled.push("USD");
return enabled;
}
/** Whether one currency may be settled by hand right now. */
async isEnabled(currency: string | null | undefined): Promise<boolean> {
const upper = currency?.toUpperCase();
if (upper !== "ETB" && upper !== "USD") return false;
const setting = await this.get();
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
}
/** Flip either toggle; an omitted field leaves that currency unchanged. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
const current = await this.get();
await this.repository.update(current.id, {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
updatedById: updatedById ?? null,
});
const updated = await this.get();
this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
);
return updated;
}
}

View File

@@ -0,0 +1,20 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
import { ManualPaymentSettingsController } from "./manual-payment-settings.controller";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
/**
* Global so billing can inject {@link ManualPaymentSettingsService} to gate
* the manual-settlement worklist and confirmation endpoint without importing
* this module (and without a cycle, since this module needs nothing back).
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ManualPaymentSetting])],
controllers: [ManualPaymentSettingsController],
providers: [ManualPaymentSettingsService],
exports: [ManualPaymentSettingsService],
})
export class PaymentSettingsModule {}

View File

@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
const ledger = new WagonStockLedger( const ledger = new WagonStockLedger(
stock.remainingByTypeId, stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1), Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
); );
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
const carriedAtBoardYard = (wagonTypeId: string): number => {
const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
};
const byWagonType = allowed const byWagonType = allowed
.filter( .filter(
({ wagonTypeId }) => ({ wagonTypeId }) =>
stock.mode !== 'TRAIN' || stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
!wagonTypeId ||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
) )
.map(({ wagonTypeId, dims }) => { .map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
return new WagonStockLedger( return new WagonStockLedger(
stock.remainingByTypeId, stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1), Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
); );
} }

View File

@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
); );
} }
await this.assertRouteCoversWagonYards(builtTrain, route);
const conflict = await this.findTrainRouteDayConflict( const conflict = await this.findTrainRouteDayConflict(
builtTrain.id, builtTrain.id,
route.id, route.id,
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
.getRepository(Locomotive) .getRepository(Locomotive)
.update({ id: In(locoIds) }, { currentYardId: station.yardId }); .update({ id: In(locoIds) }, { currentYardId: station.yardId });
} }
// Only wagons the train has actually COLLECTED move with it. On a
// consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo
// moves the Dire wagons — the ones already aboard — and picks up the
// Mojo ones standing here. Wagons waiting at yards further down the
// line stay where they are until the train physically gets to them.
const passedYardIds = stations
.filter((s) => s.sequenceNo <= dto.sequenceNo)
.map((s) => s.yardId);
await manager await manager
.getRepository(Wagon) .getRepository(Wagon)
.update( .createQueryBuilder()
{ currentTrainScheduleId: scheduleId }, .update(Wagon)
{ currentYardId: station.yardId }, .set({ currentYardId: station.yardId })
); .where('current_train_schedule_id = :scheduleId', { scheduleId })
// A yard-less wagon has no "waiting further down the line" position
// to protect, so it rides along as it always did.
.andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', {
passedYardIds,
})
.execute();
if (schedule.trainSet?.trainId) { if (schedule.trainSet?.trainId) {
await manager await manager
.getRepository(Train) .getRepository(Train)
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>(); const counts = new Map<string, { code: string; available: number }>();
// A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits.
const consistYards = builtTrainId
? new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
)
: new Set<string>();
const consistIsSplit = consistYards.size > 1;
for (const wagon of wagons) { for (const wagon of wagons) {
// Train-bound schedule: the built train's own consist IS the fleet — only // Train-bound schedule: the built train's own consist IS the fleet — only
// its wagons count (wherever they currently sit; they travel with the // its wagons count, and loose yard wagons never do. A single-yard consist
// train), and loose yard wagons never do. // counts wherever it sits (it travels with the train); a split consist is
// counted at the yard each wagon actually stands in.
if (builtTrainId) { if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue; if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
} else { } else {
// Schedule-scoped availability: pins held by OTHER schedules never // Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17 // consume a wagon here — the same physical wagon may serve the July 17
@@ -5651,12 +5680,23 @@ export class TrainSchedulingService {
// consist views draw the schedule exactly like the train builder; a schedule // consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon // created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one. // takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
// physically stands there — the train cannot load a Mojo wagon at Dire.
// A single-yard consist ignores this (the whole train is at one place).
const requiredYardId =
consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null;
const candidates = wagons const candidates = wagons
.filter( .filter(
(w) => (w) =>
w.trainId === builtTrainId && w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId && w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id), spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId),
) )
.sort((a, b) => { .sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) { if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
}); });
const remainingByTypeId = new Map<string, number>(); const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>(); const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
for (const wagon of wagons) { for (const wagon of wagons) {
remainingByTypeId.set( remainingByTypeId.set(
wagon.wagonTypeId, wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
); );
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) {
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(wagon.currentYardId, perType);
}
} }
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; // Single-yard consist (the overwhelming majority): the whole train is
// offered at every boarding yard exactly as before — the per-yard split is
// only meaningful once the consist is genuinely spread across yards.
return {
mode: 'TRAIN',
remainingByTypeId,
codesByTypeId,
...(byYardId.size > 1 ? { byYardId } : {}),
};
} }
/** /**
@@ -6160,6 +6214,45 @@ export class TrainSchedulingService {
return saved; return saved;
} }
/**
* A built train's wagons may stand in several yards. The route must pass
* through every one of them as origin or an intermediate stop — never only
* as the final destination (the train has to pick the wagons up en route).
*/
private async assertRouteCoversWagonYards(train: Train, route: Route) {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id },
select: { id: true, currentYardId: true },
});
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
if (!wagonYards.length) return;
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } });
const stops = milestones.length >= 2
? milestones.map((m) => m.yardId)
: [route.originYardId, route.destinationYardId];
// Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
if (!uncovered.length) return;
const labels = await this.yardLabelMap(uncovered);
const destination = stops[stops.length - 1];
const detail = uncovered
.map((y) =>
y === destination
? `${labels.get(y) ?? y} (only as the destination)`
: `${labels.get(y) ?? y} (not on route)`,
)
.join(', ');
throw new BadRequestException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
);
}
private async getSchedulableRoute(routeId: string) { private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({ const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId }, where: { id: routeId },

View File

@@ -43,6 +43,16 @@ export type WagonStock = {
remainingByTypeId: Map<string, number>; remainingByTypeId: Map<string, number>;
/** Wagon-type code per id, for human-readable shortfall messages. */ /** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map<string, string>; codesByTypeId: Map<string, string>;
/**
* Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
* standing at that yard. A train whose wagons are split across yards can
* only offer, at each boarding yard, the wagons physically standing there —
* a wagon waiting in Mojo is not bookable from Dire, and one picked up at
* Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
* in one yard, which keeps single-yard trains on the original whole-train
* math.
*/
byYardId?: Map<string, Map<string, number>>;
}; };
export type FlexPlanResult = { export type FlexPlanResult = {

View File

@@ -68,3 +68,94 @@ describe('WagonStockLedger', () => {
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
}); });
}); });
describe('WagonStockLedger — multi-yard consist', () => {
// The reported case: a built train of 53 wagons, 20 standing in Dire and 33
// in Mojo. Each yard may only sell the wagons physically standing there.
const DIRE = 'yard-dire';
const MOJO = 'yard-mojo';
const ADDIS = 'yard-addis';
const STOPS = [DIRE, MOJO, ADDIS];
const EDGES = STOPS.length - 1;
const splitStock = () =>
new Map([
[DIRE, new Map([['nw5', 20]])],
[MOJO, new Map([['nw5', 33]])],
]);
// Legs along Dire → Mojo → Addis.
const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 };
const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 };
const splitLedger = () =>
new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS);
it('offers each yard only the wagons standing there', () => {
const ledger = splitLedger();
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
});
it('keeps the yards independent — Dire bookings never eat Mojo stock', () => {
const ledger = splitLedger();
// A Dire booking rides the whole corridor, occupying the Mojo→Addis edge…
expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20);
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0);
// …but those are Dire's steel, so Mojo still has its own 33 to sell.
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0);
});
it('never lends a free Dire wagon to a Mojo customer', () => {
const ledger = splitLedger();
// Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty.
expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5);
// Mojo is still capped at its own 33 — the 15 empty Dire wagons are not
// offered here, exactly as the operator requires.
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33);
});
it('offers nothing at the destination — there is nothing to pick up there', () => {
const ledger = splitLedger();
// A leg boarding at the last stop has no pool of its own.
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0);
});
it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => {
const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [
'yard-add',
'yard-dire',
'yard-indode',
'yard-mojo',
'yard-djibouti',
];
const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI];
const ledger = new WagonStockLedger(
new Map([['nw5', 53]]),
stops.length - 1,
new Map([
[DIRE_2, new Map([['nw5', 20]])],
[MOJO_2, new Map([['nw5', 33]])],
]),
stops,
);
const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 });
// Addis: the train starts empty — nothing to sell.
expect(ledger.availableFor(['nw5'], to(0))).toBe(0);
// Dire: the 20 wagons waiting there.
expect(ledger.availableFor(['nw5'], to(1))).toBe(20);
// Indode: the same 20 wagons, which have moved with the train.
expect(ledger.availableFor(['nw5'], to(2))).toBe(0);
// Mojo: its own 33 only.
expect(ledger.availableFor(['nw5'], to(3))).toBe(33);
});
it('single-yard consist keeps the original whole-train behaviour', () => {
// No byYardId (the train is not split) — every leg sees the whole train,
// exactly as before this feature.
const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES);
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53);
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
});
});

View File

@@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util';
* Gelan→Adama never competes for stock with an export on Adama→Doraleh. * Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/ */
export class WagonStockLedger { export class WagonStockLedger {
/**
* Usage rows keyed by pool. A single-yard train has one pool (''), so this is
* exactly the original per-type accounting. A multi-yard consist keys by
* boarding yard as well, because the Dire wagons and the Mojo wagons are
* disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the
* Mojo→Addis edge, but they must not shrink what Mojo itself can offer.
*/
private readonly usedPerEdge = new Map<string, number[]>(); private readonly usedPerEdge = new Map<string, number[]>();
constructor( constructor(
private readonly remainingByTypeId: Map<string, number>, private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number, private readonly edgeCount: number,
/**
* Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons
* standing at each yard. When present, a leg is served ONLY by the wagons
* standing at the yard it boards from — a Dire→Addis booking on a train
* whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis
* booking sees 33, never the Dire wagons that ride past empty.
*/
private readonly byYardId?: Map<string, Map<string, number>>,
/** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */
private readonly stops: readonly string[] = [],
) {} ) {}
/** The yard a leg boards from, or '' when the train is not split across yards. */
private poolYardOf(leg: CorridorLeg): string {
if (!this.byYardId) return '';
return this.stops[leg.fromEdge] ?? '';
}
/** Usage-row key: one row per (pool, wagon type). */
private rowKey(wagonTypeId: string, leg: CorridorLeg): string {
const pool = this.poolYardOf(leg);
return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
}
/** Wagons of one type offered at the yard a leg boards from. */
private totalForType(wagonTypeId: string, leg: CorridorLeg): number {
const pool = this.poolYardOf(leg);
if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0;
return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0;
}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number { private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; const total = this.totalForType(wagonTypeId, leg);
const row = this.usedPerEdge.get(wagonTypeId); const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
if (!row) return total; if (!row) return total;
let busiest = 0; let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
@@ -69,10 +105,11 @@ export class WagonStockLedger {
if (!deepest) break; if (!deepest) break;
const take = Math.min(outstanding, deepest.free); const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id); const key = this.rowKey(deepest.id, leg);
let row = this.usedPerEdge.get(key);
if (!row) { if (!row) {
row = new Array<number>(this.edgeCount).fill(0); row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row); this.usedPerEdge.set(key, row);
} }
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take; row[edge] = (row[edge] ?? 0) + take;

View File

@@ -44,7 +44,7 @@ export class BuildTrainDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
type: [String], type: [String],
format: 'uuid', format: 'uuid',
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', description: 'Wagons to attach at build time, in consist order (any yard)',
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()

View File

@@ -283,6 +283,10 @@ export class TrainBuilderService {
wagonNumber: wagon.wagonNumber, wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber, sequenceNumber: wagon.sequenceNumber,
status: wagon.status, status: wagon.status,
currentYardId: wagon.currentYardId ?? null,
currentYard: wagon.currentYard
? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label }
: null,
wagonType: wagon.wagonType wagonType: wagon.wagonType
? { ? {
id: wagon.wagonType.id, id: wagon.wagonType.id,
@@ -326,6 +330,27 @@ export class TrainBuilderService {
: null, : null,
locomotives, locomotives,
wagons, wagons,
// Where the consist physically stands. A train built from several yards
// only picks a yard's wagons up when it reaches that yard, and a customer
// boarding there can only book the wagons standing there — the schedule
// route must therefore cover every one of these yards before its
// destination.
wagonYards: [
...wagons
.reduce((acc, wagon) => {
const id = wagon.currentYardId ?? 'UNASSIGNED';
const entry = acc.get(id) ?? {
yardId: wagon.currentYardId ?? null,
code: wagon.currentYard?.code ?? null,
label: wagon.currentYard?.label ?? null,
wagonCount: 0,
};
entry.wagonCount += 1;
acc.set(id, entry);
return acc;
}, new Map<string, { yardId: string | null; code: string | null; label: string | null; wagonCount: number }>())
.values(),
].sort((a, b) => b.wagonCount - a.wagonCount),
totals: { totals: {
wagonCount: wagons.length, wagonCount: wagons.length,
totalTareTons, totalTareTons,
@@ -428,10 +453,13 @@ export class TrainBuilderService {
} }
/** /**
* Relocate the train to another yard. The consist moves as one unit: every * Relocate the train to another yard. The locomotives always follow. Of the
* coupled locomotive and wagon follows to the new yard (so their current * wagons, only those standing WITH the train move: on a consist spread
* yards always match the train's), and each wagon gets a movement-ledger row. * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
* Blocked while the train is out on a dispatched run. * relocates the 20 it is actually pulling and leaves the Mojo wagons where
* they stand — the train collects those by arriving, not by this call.
* Each moved wagon gets a movement-ledger row. Blocked while the train is
* out on a dispatched run.
*/ */
async setYard(id: string, currentYardId: string) { async setYard(id: string, currentYardId: string) {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -439,6 +467,7 @@ export class TrainBuilderService {
if (train.currentYardId === currentYardId) return; if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
const previousYardId = train.currentYardId ?? null;
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
@@ -454,7 +483,17 @@ export class TrainBuilderService {
); );
} }
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); const allWagons = await manager
.getRepository(Wagon)
.find({ where: { trainId: train.id } });
// Wagons travelling with the train = those at the yard it is leaving.
// A yard-less wagon has no standing position of its own, so it follows.
const wagons = allWagons.filter(
(wagon) =>
wagon.currentYardId == null ||
previousYardId == null ||
wagon.currentYardId === previousYardId,
);
const now = new Date(); const now = new Date();
for (const wagon of wagons) { for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue; if (wagon.currentYardId === yard.id) continue;
@@ -475,7 +514,7 @@ export class TrainBuilderService {
return this.getComposition(id); return this.getComposition(id);
} }
/** Append AVAILABLE wagons from the train's own yard to the consist. */ /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id); const train = await this.getEditableTrain(manager, id);
@@ -1037,11 +1076,8 @@ export class TrainBuilderService {
if (wagon.status !== WagonStatus.Available) { if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
} }
if (wagon.currentYardId !== train.currentYardId) { // Wagons may sit in any yard — the schedule's route must pass through
throw new BadRequestException( // every wagon yard before its destination (checked at scheduling time).
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
toAttach.push(wagon); toAttach.push(wagon);
} }
if (!toAttach.length) return []; if (!toAttach.length) return [];

View File

@@ -342,8 +342,8 @@ export class WagonsService {
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
const wagon = await this.findById(wagonId); const wagon = await this.findById(wagonId);
// Mirror train-builder attachWagons: only a truly free, available wagon in // Mirror train-builder attachWagons: only a truly free, available wagon
// the train's own yard can be coupled, and never onto a dispatched train. // (any yard) can be coupled, and never onto a dispatched train.
if (wagon.trainId != null) { if (wagon.trainId != null) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
} }
@@ -358,11 +358,6 @@ export class WagonsService {
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
); );
} }
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
const maxSeq = await this.wagonRepo const maxSeq = await this.wagonRepo
.createQueryBuilder('w') .createQueryBuilder('w')

View File

@@ -1471,6 +1471,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:exchange_rate:manage", "edr_freight_app:settings:exchange_rate:manage",
"Set the USD-ETB fallback rate", "Set the USD-ETB fallback rate",
), ),
perm(
"b4d00001-0001-4000-8000-000000000003",
"edr_freight_app:settings:manual_payment:view",
"View the manual (offline) payment channel settings",
),
perm(
"b4d00001-0001-4000-8000-000000000004",
"edr_freight_app:settings:manual_payment:manage",
"Enable or disable manual invoice settlement per currency",
),
perm( perm(
"b4e00001-0001-4000-8000-000000000001", "b4e00001-0001-4000-8000-000000000001",
"edr_freight_app:settings:contract_templates:view", "edr_freight_app:settings:contract_templates:view",
@@ -2123,6 +2133,14 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", manage: "edr_freight_app:settings:exchange_rate:manage",
}, },
// Whether Finance may settle invoices by hand, per currency. Split
// view/manage on purpose: Finance reads it (the worklist offers only the
// enabled currencies) but must not switch its own channel on — same
// maker-checker split as the other sensitive finance settings.
manualPayment: {
view: "edr_freight_app:settings:manual_payment:view",
manage: "edr_freight_app:settings:manual_payment:manage",
},
contractTemplates: { contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view", view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage", manage: "edr_freight_app:settings:contract_templates:manage",
@@ -2427,6 +2445,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.export,
// Manual settlement (bank transfer / counter) of USD and ETB invoices. // Manual settlement (bank transfer / counter) of USD and ETB invoices.
FREIGHT_PERMS.invoices.confirmOffline, FREIGHT_PERMS.invoices.confirmOffline,
// Read-only: the worklist offers whichever currencies are switched on.
// Flipping the switch is deliberately NOT here — see `manualPayment`.
FREIGHT_PERMS.settings.manualPayment.view,
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
// (the cron sweep runs as the system); these are the *manual* exceptional-operations // (the cron sweep runs as the system); these are the *manual* exceptional-operations

View File

@@ -86,6 +86,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage"; import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage"; import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -1156,6 +1157,18 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="configuration/manual-payments"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.manualPayment.view}
>
<div className="p-4">
<ManualPaymentSettingsCard />
</div>
</RequirePermission>
}
/>
<Route <Route
path="configuration/exchange-rate" path="configuration/exchange-rate"
element={ element={

View File

@@ -562,6 +562,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate", href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view, permission: FREIGHT_PERMS.settings.exchangeRate.view,
}, },
{
label: "Manual payments",
href: "/dashboard/configuration/manual-payments",
permission: FREIGHT_PERMS.settings.manualPayment.view,
},
], ],
}, },
{ {

View File

@@ -4,34 +4,40 @@ import {
Button, Button,
Checkbox, Checkbox,
Group, Group,
Pagination,
ScrollArea, ScrollArea,
Select, Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react"; import { MapPin, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20;
import { api } from "@/services/api"; import { api } from "@/services/api";
/** /**
* AVAILABLE wagons standing in the train's own yard — the only ones that can * AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* be coupled. Pick any number and append them to the consist. * so the picker never page-walks the whole fleet into the browser.
*/ */
export default function AvailableWagonsPanel({ export default function AvailableWagonsPanel({
yardId, homeYardId,
yardLabel,
onAssign, onAssign,
assigning, assigning,
exportTrainNumber, exportTrainNumber,
importTrainNumber, importTrainNumber,
}: AvailableWagonsPanelProps) { }: AvailableWagonsPanelProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [typeFilter, setTypeFilter] = useState<string>("ALL"); const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false); const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one. // The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber const runLabel = exportTrainNumber
@@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({
: null; : null;
const wagonsQuery = useQuery( const wagonsQuery = useQuery(
api.wagons.list.queryOptions({ api.wagons.listPaged.queryOptions({
input: { input: {
filters: { filters: {
status: Freight.WagonStatus.Available, status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled. // Loose wagons only — one already on another train cannot be coupled.
unassigned: true, unassigned: true,
search: debouncedSearch.trim() || undefined,
currentYardId: yardFilter === "ALL" ? undefined : yardFilter,
wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter,
// Rostered to this train's run — the API matches either run column.
trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined,
page,
pageSize: PAGE_SIZE,
}, },
}, },
enabled: Boolean(yardId),
}), }),
); );
const wagons = useMemo(() => { const wagons = wagonsQuery.data?.items ?? [];
const q = search.trim().toLowerCase(); const total = wagonsQuery.data?.meta.total ?? 0;
return (wagonsQuery.data ?? []).filter((wagon) => { const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
// Rostered to this train's run — match on the export run, which fixes the
// import run anyway.
if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
const runMatchCount = useMemo( // Filters change → back to page 1 (and clamp when the list shrinks).
() => useEffect(() => {
exportTrainNumber setPage(1);
? (wagonsQuery.data ?? []).filter( }, [debouncedSearch, typeFilter, yardFilter, runOnly]);
(w) => w.exportTrainNumber === exportTrainNumber, useEffect(() => {
).length if (page > totalPages) setPage(totalPages);
: 0, }, [page, totalPages]);
[wagonsQuery.data, exportTrainNumber],
);
const typeOptions = useMemo(() => { // Dropdowns come from the reference lists, not the current page — a yard or
const byId = new Map<string, string>(); // type must stay pickable even when this page holds none of it.
for (const wagon of wagonsQuery.data ?? []) { const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
if (wagon.wagonType) { const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set( const yardOptions = useMemo(() => {
wagon.wagonType.id, const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
wagon.wagonType.code a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
? `${wagon.wagonType.name} (${wagon.wagonType.code})` );
: wagon.wagonType.name,
);
}
}
return [ return [
{ value: "ALL", label: "All types" }, { value: "ALL", label: "All yards" },
...[...byId.entries()].map(([value, label]) => ({ value, label })), ...yards.map((yard) => ({
value: yard.id,
label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`,
})),
]; ];
}, [wagonsQuery.data]); }, [yardsQuery.data, homeYardId]);
const typeOptions = useMemo(
() => [
{ value: "ALL", label: "All types" },
// e.g. "Flat wagon (NW5)" — name with its type code.
...(wagonTypesQuery.data ?? []).map((type) => ({
value: type.id,
label: type.code ? `${type.name} (${type.code})` : type.name,
})),
],
[wagonTypesQuery.data],
);
const toggle = (wagonId: string, checked: boolean) => { const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) => setSelected((prev) =>
@@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({
); );
}; };
const allSelected = // Select-all covers this page only — the rest of the matches are not loaded.
wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id)); const someSelected = wagons.some((w) => selected.includes(w.id));
const toggleAll = (checked: boolean) => { const toggleAll = (checked: boolean) => {
@@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({
onChange={(v) => setTypeFilter(v ?? "ALL")} onChange={(v) => setTypeFilter(v ?? "ALL")}
/> />
</Group> </Group>
<Select
size="sm"
leftSection={<MapPin size={14} />}
data={yardOptions}
value={yardFilter}
onChange={(v) => setYardFilter(v ?? "ALL")}
searchable
aria-label="Filter by yard"
/>
{runLabel ? ( {runLabel ? (
<Checkbox <Checkbox
size="sm" size="sm"
label={`Only wagons on this train's run (${runLabel})${runMatchCount} here`} label={`Only wagons on this train's run (${runLabel})`}
checked={runOnly} checked={runOnly}
onChange={(e) => setRunOnly(e.currentTarget.checked)} onChange={(e) => setRunOnly(e.currentTarget.checked)}
/> />
) : null} ) : null}
{wagons.length ? ( {wagons.length ? (
<Checkbox <Group justify="space-between" wrap="nowrap">
size="sm" <Checkbox
label={`Select all (${wagons.length})`} size="sm"
checked={allSelected} label={`Select all on this page (${wagons.length})`}
indeterminate={!allSelected && someSelected} checked={allSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)} indeterminate={!allSelected && someSelected}
/> onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
{selected.length ? (
<Text size="xs" c="dimmed">
{selected.length} selected
</Text>
) : null}
</Group>
) : null} ) : null}
<ScrollArea.Autosize mah={380} type="auto"> <ScrollArea.Autosize mah={380} type="auto">
@@ -166,7 +194,7 @@ export default function AvailableWagonsPanel({
</Text> </Text>
) : !wagons.length ? ( ) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm"> <Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"} No available wagons match
</Text> </Text>
) : ( ) : (
wagons.map((wagon) => ( wagons.map((wagon) => (
@@ -191,6 +219,15 @@ export default function AvailableWagonsPanel({
<Text size="sm" fw={600} ff="monospace" truncate> <Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber} {wagon.wagonNumber}
</Text> </Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? ( {wagon.exportTrainNumber ? (
<Badge <Badge
size="xs" size="xs"
@@ -217,6 +254,15 @@ export default function AvailableWagonsPanel({
</Stack> </Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
{totalPages > 1 ? (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} of {total}
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
<Button <Button
leftSection={<Plus size={16} />} leftSection={<Plus size={16} />}
disabled={!selected.length} disabled={!selected.length}
@@ -230,8 +276,8 @@ export default function AvailableWagonsPanel({
} }
export interface AvailableWagonsPanelProps { export interface AvailableWagonsPanelProps {
yardId: string; /** The train's own yard — sorted first and highlighted; not a restriction. */
yardLabel?: string | null; homeYardId: string | null;
onAssign: (wagonIds: string[]) => void; onAssign: (wagonIds: string[]) => void;
assigning: boolean; assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */ /** This train's odd EXPORT run — drives the "only this run" filter. */

View File

@@ -7,7 +7,7 @@ import {
type DropResult, type DropResult,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2, Wrench } from "lucide-react"; import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -191,6 +191,11 @@ function WagonRow({
{wagon.wagonType.code} {wagon.wagonType.code}
</Badge> </Badge>
) : null} ) : null}
{wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{wagon.currentYard.label ?? wagon.currentYard.code}
</Badge>
) : null}
</Group> </Group>
<Text size="xs" c="dimmed" truncate> <Text size="xs" c="dimmed" truncate>
{wagon.wagonType {wagon.wagonType

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings", BASE: "/exchange-settings",
}, },
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
},
AUDIT_LOGS: { AUDIT_LOGS: {
BASE: "/audit", BASE: "/audit",
}, },

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
manualPaymentSettingsService,
type ManualPaymentSettings,
} from "@/services/manualPaymentSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
export const useManualPaymentSettingsQuery = () =>
useQuery({
queryKey: MANUAL_PAYMENT_SETTINGS_KEY,
queryFn: () => manualPaymentSettingsService.get(),
staleTime: 60_000,
});
export const useUpdateManualPaymentSettings = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
// The Manual Payments worklist only lists enabled currencies.
queryClient.invalidateQueries({ queryKey: ["invoices"] });
toast.success(
t("manualPaymentSettings.updated", "Manual payment settings updated"),
);
},
onError: handleError,
});
};

View File

@@ -376,6 +376,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", manage: "edr_freight_app:settings:exchange_rate:manage",
}, },
// Whether Finance may settle invoices by hand, per currency. Finance holds
// `view` (the worklist offers only enabled currencies); `manage` is admin.
manualPayment: {
view: "edr_freight_app:settings:manual_payment:view",
manage: "edr_freight_app:settings:manual_payment:manage",
},
contractTemplates: { contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view", view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage", manage: "edr_freight_app:settings:contract_templates:manage",

View File

@@ -1,8 +1,9 @@
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { Landmark, Receipt } from "lucide-react"; import { Banknote, DollarSign, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -16,7 +17,9 @@ import UsdPaymentsPanel from "./UsdPaymentsPage";
* before, and just doesn't render if the user lacks it. * before, and just doesn't render if the user lacks it.
* *
* The Payments tab was removed; its summary (total collected, ETB/USD) now * The Payments tab was removed; its summary (total collected, ETB/USD) now
* lives as a card at the top of the Invoices tab instead. * lives as a card at the top of the Invoices tab instead. Manual payments are
* split into one tab per currency — ETB keeps the original `?tab=manual-payments`
* key so existing links and the old redirect still land somewhere valid.
*/ */
const TABS = [ const TABS = [
{ {
@@ -30,13 +33,25 @@ const TABS = [
}, },
{ {
key: "manual-payments", key: "manual-payments",
label: "Manual Payments", label: "Manual Payments (ETB)",
icon: Landmark, icon: Banknote,
// Same gate as Invoices, not a dedicated key — mirrors the old route. // Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view, permission: FREIGHT_PERMS.invoices.view,
/** Hidden unless manual settlement is switched on for this currency. */
manualCurrency: "ETB",
subtitle: subtitle:
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.", "Import and export invoices in ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel, Panel: () => <UsdPaymentsPanel currency="ETB" />,
},
{
key: "manual-payments-usd",
label: "Manual Payments (USD)",
icon: DollarSign,
permission: FREIGHT_PERMS.invoices.view,
manualCurrency: "USD",
subtitle:
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: () => <UsdPaymentsPanel currency="USD" />,
}, },
] as const; ] as const;
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
const { user } = useAuth(); const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission)); // A currency whose manual-payment channel is switched off has no tab at all
// — the list would be empty and every confirmation refused.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const manualEnabled = (currency: "ETB" | "USD") =>
!manualSettings ||
(currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
const visibleTabs = TABS.filter(
(tab) =>
hasPermission(user, tab.permission) &&
(!("manualCurrency" in tab) || manualEnabled(tab.manualCurrency)),
);
const requested = searchParams.get("tab"); const requested = searchParams.get("tab");
const active: TabKey = const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ?? visibleTabs.find((tab) => tab.key === requested)?.key ??

View File

@@ -27,6 +27,7 @@ import {
} from "@/components/customers"; } from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice"; import type { OfflineUsdInvoice } from "@/types/invoice";
@@ -144,7 +145,11 @@ function ConfirmCell({
* Finance settles by hand; confirming records the payment the same way an * Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically. * online payment would, so the booking advances identically.
*/ */
export default function UsdPaymentsPanel() { export default function UsdPaymentsPanel({
currency,
}: {
currency: "USD" | "ETB";
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"", "",
); );
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null); const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null); const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState(""); const [reference, setReference] = useState("");
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
FREIGHT_PERMS.invoices.confirmOffline, FREIGHT_PERMS.invoices.confirmOffline,
); );
// Manual settlement is switched on per currency in Configuration → Manual
// payments. FinanceHubPage hides the tab for a disabled currency; this is
// the fallback for a direct `?tab=` link, and the API refuses regardless.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const currencyEnabled = manualSettings
? currency === "ETB"
? manualSettings.etbEnabled
: manualSettings.usdEnabled
: true;
const filter = useMemo( const filter = useMemo(
() => ({ () => ({
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
search: debouncedQuery, search: debouncedQuery,
status: statusFilter || undefined, status: statusFilter || undefined,
currency: currency || undefined, currency,
}), }),
[ [
pagination.pageIndex, pagination.pageIndex,
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
], ],
); );
const { data, isLoading, isError, refetch, isFetching } = useQuery( const { data, isLoading, isError, refetch, isFetching } = useQuery({
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }), ...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
); enabled: currencyEnabled,
});
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions()); const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
@@ -289,20 +304,6 @@ export default function UsdPaymentsPanel() {
); );
}, },
}, },
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{ {
id: "status", id: "status",
header: "Status", header: "Status",
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
meta: { headerClassName: "text-right", cellClassName: "text-right" }, meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => { cell: ({ row }) => {
if (row.original.status === "PAID" || !canConfirm) return null; if (row.original.status === "PAID" || !canConfirm) return null;
if (!currencyEnabled) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />; return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
}, },
}, },
], ],
[canConfirm, navigate], [canConfirm, currencyEnabled, navigate],
); );
return ( return (
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }} style={{ flex: 1, minWidth: "240px" }}
radius="lg" radius="lg"
/> />
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl <SegmentedControl
size="sm" size="sm"
radius="md" radius="md"
@@ -427,9 +415,11 @@ export default function UsdPaymentsPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"} status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={ emptyMessage={
debouncedQuery !currencyEnabled
? "No invoices match your search." ? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
: "No invoices awaiting manual payment confirmation." : debouncedQuery
? "No invoices match your search."
: `No ${currency} invoices awaiting manual payment confirmation.`
} }
error={ error={
isError isError

View File

@@ -0,0 +1,132 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge";
import { Switch } from "@/shared/common/ui/switch";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
useManualPaymentSettingsQuery,
useUpdateManualPaymentSettings,
} from "@/hooks/useManualPaymentSettings";
type Currency = "ETB" | "USD";
const CURRENCIES: {
code: Currency;
field: "etbEnabled" | "usdEnabled";
icon: typeof Banknote;
title: string;
description: string;
}[] = [
{
code: "ETB",
field: "etbEnabled",
icon: Banknote,
title: "Birr (ETB) invoices",
description:
"ETB invoices are normally paid online by the customer. Switch this on when Finance also needs to settle them by hand — a bank transfer or a payment at the counter.",
},
{
code: "USD",
field: "usdEnabled",
icon: Landmark,
title: "Dollar (USD) invoices",
description:
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
},
];
/**
* Switches the manual (offline) payment channel on or off per currency.
*
* Off means gone, not greyed out: the Manual Payments worklist lists only
* enabled currencies, and the API refuses a confirmation in a disabled one —
* so a stale tab or a direct call cannot slip a payment through.
*/
export default function ManualPaymentSettingsCard() {
const { user } = useAuth();
const canManage =
hasPermission(user, FREIGHT_PERMS.settings.manualPayment.manage) ||
hasPermission(user, FREIGHT_PERMS.admin);
const { data, isLoading } = useManualPaymentSettingsQuery();
const update = useUpdateManualPaymentSettings();
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Manual payments</CardTitle>
<CardDescription>
Whether Finance staff may mark invoices as paid by hand, from
Invoices Manual Payments. Each currency is switched separately.
Confirming still requires the payment slip and the booking&apos;s pay
window to be open.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{noneEnabled && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Both currencies are off the Manual Payments list is empty and
Finance cannot settle any invoice by hand.
</p>
</div>
)}
{isLoading || !data
? CURRENCIES.map((c) => (
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
))
: CURRENCIES.map(({ code, field, icon: Icon, title, description }) => {
const enabled = data[field];
return (
<div
key={code}
className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<p className="font-medium">{title}</p>
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
<Switch
checked={enabled}
disabled={!canManage || update.isPending}
aria-label={`Allow manual payment for ${code} invoices`}
onCheckedChange={(checked) =>
update.mutate({ [field]: checked })
}
/>
</div>
);
})}
{!canManage && (
<p className="text-sm text-muted-foreground">
You can see these settings but not change them that needs the
manual-payment settings permission.
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -278,6 +278,29 @@ export default function TrainBuilderDetailPage() {
]} ]}
/> />
{composition.wagonYards.length > 1 ? (
<Alert color="blue" icon={<MapPin size={16} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>
This train's wagons stand in {composition.wagonYards.length} yards
</Text>
<Group gap="xs">
{composition.wagonYards.map((group) => (
<Badge key={group.yardId ?? "none"} variant="light" color="blue">
{group.label ?? group.code ?? "No yard"} · {group.wagonCount} wagon
{group.wagonCount === 1 ? "" : "s"}
</Badge>
))}
</Group>
<Text size="xs" c="dimmed">
The train collects each group when it reaches that yard, so the schedule's route
must pass through every one of them before its destination. Customers boarding at
a yard can only book the wagons standing there.
</Text>
</Stack>
</Alert>
) : null}
{!composition.editable ? ( {!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}> <Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival. This train is out on a dispatched run its composition is frozen until arrival.
@@ -379,13 +402,13 @@ export default function TrainBuilderDetailPage() {
<Grid.Col span={{ base: 12, md: 5 }}> <Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%"> <Card h="100%">
<Stack gap="sm"> <Stack gap="sm">
<Text fw={600}>Available wagons — {yard?.label ?? "yard"}</Text> <Text fw={600}>Available wagons — all yards</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Only AVAILABLE wagons standing in the train's own yard can be coupled. AVAILABLE, unassigned wagons from every yard can be coupled. The schedule's
route must pass through each wagon's yard before its destination.
</Text> </Text>
<AvailableWagonsPanel <AvailableWagonsPanel
yardId={yard?.id ?? ""} homeYardId={yard?.id ?? null}
yardLabel={yard?.label}
exportTrainNumber={composition.exportTrainNumber} exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber} importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending} assigning={assignWagons.isPending}

View File

@@ -0,0 +1,36 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
/**
* Whether Finance may settle invoices by hand (bank transfer / counter) rather
* than the customer paying online — switched per currency, because the two
* channels are operationally different.
*/
export interface ManualPaymentSettings {
etbEnabled: boolean;
usdEnabled: boolean;
updatedById: string | null;
updatedAt?: string;
}
export const manualPaymentSettingsService = {
get: async (): Promise<ManualPaymentSettings> => {
const response = await client.get<ApiResponse<ManualPaymentSettings>>(BASE);
return unwrap(response.data);
},
/** Partial: an omitted currency keeps its current setting. */
update: async (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE,
patch,
);
return unwrap(response.data);
},
};

View File

@@ -67,6 +67,8 @@ export interface TrainCompositionWagon {
wagonNumber: string; wagonNumber: string;
sequenceNumber: number | null; sequenceNumber: number | null;
status: string; status: string;
currentYardId: string | null;
currentYard: YardRefLite | null;
wagonType: { wagonType: {
id: string; id: string;
code: string; code: string;
@@ -77,6 +79,14 @@ export interface TrainCompositionWagon {
} | null; } | null;
} }
/** Where a built train's wagons physically stand, largest group first. */
export interface TrainWagonYardGroup {
yardId: string | null;
code: string | null;
label: string | null;
wagonCount: number;
}
export interface TrainCompositionTotals { export interface TrainCompositionTotals {
wagonCount: number; wagonCount: number;
totalTareTons: number; totalTareTons: number;
@@ -104,6 +114,7 @@ export interface TrainComposition {
currentYard: YardRefLite | null; currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[]; locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[]; wagons: TrainCompositionWagon[];
wagonYards: TrainWagonYardGroup[];
totals: TrainCompositionTotals; totals: TrainCompositionTotals;
activeSchedules: ActiveScheduleRef[]; activeSchedules: ActiveScheduleRef[];
editable: boolean; editable: boolean;

View File

@@ -46,10 +46,19 @@ const useAuth = () => {
}), }),
); );
// `/companies/getInfo` is customer-only (PortalCustomerGuard). A staff
// account signed into the portal 403s on every call, and because a failed
// fetch leaves `company` null the onboarding gate redirects, which remounts
// the route tree, which refires the query — a request storm. Never ask.
const isCustomerAccount =
authQuery.data?.userType === "individual" ||
authQuery.data?.userType === "external_organization";
const companyQuery = useQuery( const companyQuery = useQuery(
api.companies.getInfo.queryOptions({ api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id, enabled: !!authQuery.data?.id && isCustomerAccount,
retry: false, retry: false,
refetchOnMount: false,
staleTime(query) { staleTime(query) {
// Fast-poll while anything is awaiting a backoffice decision: an // Fast-poll while anything is awaiting a backoffice decision: an
@@ -61,7 +70,7 @@ const useAuth = () => {
(p) => p.status !== "active", (p) => p.status !== "active",
) )
) )
return 60; return 60_000;
return 10 * 60 * 1000; return 10 * 60 * 1000;
}, },
@@ -308,7 +317,11 @@ const useAuth = () => {
logout, logout,
authQuery, authQuery,
companyQuery, companyQuery,
customerQuery: companyQuery, // A disabled query reports `isPending` forever; the route gates read this
// to decide "still loading", so a staff account would sit on a spinner.
customerQuery: isCustomerAccount
? companyQuery
: { ...companyQuery, isPending: false },
}; };
}; };

View File

@@ -6,15 +6,20 @@ import {
Box, Box,
Button, Button,
Checkbox, Checkbox,
CloseButton,
Drawer,
Flex,
Group, Group,
Image, Image,
Loader, Loader,
Modal, Modal,
Paper, Paper,
PinInput, PinInput,
ScrollArea,
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { import {
ArrowLeft, ArrowLeft,
@@ -24,6 +29,7 @@ import {
RotateCw, RotateCw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { useMediaQuery } from "@mantine/hooks";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
@@ -44,6 +50,9 @@ export default function ContractViewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
// Below Mantine's `sm`: the signing surfaces go full-bleed and the consent
// bar stacks, so the checkbox and its button stop crowding each other.
const isMobile = useMediaQuery("(max-width: 48em)");
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [otpOpen, setOtpOpen] = useState(false); const [otpOpen, setOtpOpen] = useState(false);
@@ -315,7 +324,7 @@ export default function ContractViewPage() {
</Paper> </Paper>
</Box> </Box>
{data.canSignCustomer && hasScrolledToBottom && ( {data.canSignCustomer && hasScrolledToBottom && !signOpen && !otpOpen && !successOpen && (
<Paper <Paper
withBorder withBorder
radius="lg" radius="lg"
@@ -325,87 +334,169 @@ export default function ContractViewPage() {
maw={920} maw={920}
mx="auto" mx="auto"
style={{ style={{
// Above SupportWidget's Affix (zIndex 300) — the fixed chat FAB // No z-index escalation here: raising this bar above the chat FAB
// shares this bottom-right corner and would otherwise render on // is what made it fight every overlay on the page. It keeps clear
// top of the required agree-and-sign bar. // of the fixed FAB by leaving room for it instead (paddingRight on
position: "relative", // wide screens, where the FAB sits beside the bar's right edge).
zIndex: 301,
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
}} }}
pr={{ base: "md", sm: 88 }}
> >
<Box maw={920} mx="auto"> <Flex
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm"> direction={{ base: "column", sm: "row" }}
<Checkbox align={{ base: "stretch", sm: "center" }}
checked={agreedToTerms} justify="space-between"
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)} gap="sm"
disabled={!hasScrolledToBottom} >
label={CONSENT_TEXT} <Checkbox
description={ checked={agreedToTerms}
hasScrolledToBottom onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
? "You may now sign the contract." disabled={!hasScrolledToBottom}
: "Read the full contract above before you can agree and sign." label={CONSENT_TEXT}
} description={
/> hasScrolledToBottom
<Button ? "You may now sign the contract."
color="edr-green" : "Read the full contract above before you can agree and sign."
leftSection={<FileSignature size={16} />} }
disabled={!canProceedToSign} />
onClick={openSign} <Button
> color="edr-green"
{usingSaved ? "Approve & sign" : "Sign contract"} leftSection={<FileSignature size={16} />}
</Button> disabled={!canProceedToSign}
</Group> onClick={openSign}
</Box> fullWidth={isMobile}
style={{ flexShrink: 0 }}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Flex>
</Paper> </Paper>
)} )}
<Modal {/* A right-hand drawer, not a centered dialog: the sign step shares the
bottom-right corner with the agree-and-sign bar and the support-chat
FAB, and a full-height side panel simply never contends with them. */}
<Drawer
opened={signOpen} opened={signOpen}
onClose={() => setSignOpen(false)} onClose={() => setSignOpen(false)}
title={usingSaved ? "Approve signature" : "Sign contract"} position={isMobile ? "bottom" : "right"}
centered size={isMobile ? "100%" : "lg"}
radius="lg" padding={0}
withCloseButton={false}
// Clear of the sign bar (301) and the chat FAB's Affix (300).
zIndex={400}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
// Drawer.Body has no intrinsic height, so the inner flex column (pinned
// header / scrolling middle / pinned footer) would collapse without this.
styles={{
content: { display: "flex", flexDirection: "column" },
body: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" },
}}
> >
<Stack gap="md"> <>
<Text size="sm" c="dimmed"> {/* Header — pinned, so the contract reference stays visible while the
{data.reference} your signature is stored securely on the signature and stamp sections scroll. */}
contract. <Group
</Text> justify="space-between"
wrap="nowrap"
p="lg"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<FileSignature size={19} />
</ThemeIcon>
<Box>
<Text fw={600}>
{usingSaved ? "Approve signature" : "Sign contract"}
</Text>
<Text size="xs" c="dimmed">
{data.reference}
</Text>
</Box>
</Group>
<CloseButton size="lg" onClick={() => setSignOpen(false)} />
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<Stack gap="lg" p="lg">
<TextInput <TextInput
label="Full name" label="Full name"
description="Printed under your signature on the contract."
placeholder="Name as it should appear on the contract"
withAsterisk
value={signerName} value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)} onChange={(e) => setSignerName(e.currentTarget.value)}
/> />
{usingSaved ? (
<Stack gap="xs"> <Box>
<Group justify="space-between" align="center" mb={6} wrap="nowrap">
<Text size="sm" fw={500}>
Signature{" "}
<Text span c="red">
*
</Text>
</Text>
{usingSaved ? (
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
leftSection={<RotateCw size={13} />}
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new one
</Button>
) : savedSignatureImage ? (
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => {
setDrawNew(false);
setSignatureData(null);
}}
>
Use saved signature
</Button>
) : null}
</Group>
{usingSaved ? (
<Paper <Paper
withBorder withBorder
radius="md" radius="md"
p="xs" p="sm"
style={{ borderStyle: "dashed" }} style={{
borderStyle: "dashed",
background: "var(--mantine-color-gray-0)",
}}
> >
<Image <Image
src={savedSignatureImage ?? undefined} src={savedSignatureImage ?? undefined}
alt="Saved signature" alt="Saved signature"
fit="contain" fit="contain"
h={140} h={120}
/> />
<Group gap={6} mt="xs" justify="center" wrap="nowrap">
<ShieldCheck
size={13}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="xs" c="dimmed">
Saved signature stored securely on your profile
</Text>
</Group>
</Paper> </Paper>
<Button ) : (
variant="subtle" <ContractSignaturePad onChange={setSignatureData} />
size="compact-xs" )}
color="edr-green" </Box>
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</Stack>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload <StampUpload
value={stampData} value={stampData}
@@ -413,26 +504,47 @@ export default function ContractViewPage() {
description="Attach your official company stamp or seal — it is applied to the contract next to your signature." description="Attach your official company stamp or seal — it is applied to the contract next to your signature."
/> />
<Group justify="flex-end" gap="sm"> </Stack>
<Button variant="default" onClick={() => setSignOpen(false)}> </ScrollArea>
Cancel
</Button> {/* Footer — pinned, so the primary action never scrolls out of reach. */}
<Button <Stack
color="edr-green" gap="sm"
loading={sendOtpMutation.isPending} p="lg"
disabled={ style={{
sendOtpMutation.isPending || borderTop: "1px solid var(--mantine-color-gray-2)",
!signerName.trim() || background: "var(--mantine-color-body)",
(!usingSaved && !signatureData) || flexShrink: 0,
!stampData }}
} >
onClick={confirmSign} <Group gap={6} wrap="nowrap">
> <ShieldCheck size={14} color="var(--mantine-color-dimmed)" />
Continue to verification <Text size="xs" c="dimmed">
</Button> We send a 6-digit code to your registered contacts next.
</Group> </Text>
</Stack> </Group>
</Modal> <Group gap="sm" grow>
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<ShieldCheck size={16} />}
loading={sendOtpMutation.isPending}
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>
Continue to verification
</Button>
</Group>
</Stack>
</>
</Drawer>
<Modal <Modal
opened={otpOpen} opened={otpOpen}
@@ -440,6 +552,10 @@ export default function ContractViewPage() {
title="Verify it's you" title="Verify it's you"
centered centered
radius="lg" radius="lg"
zIndex={400}
// Full-bleed on phones — a centered dialog plus the fixed chat FAB left
// the code entry and its buttons fighting for the same few pixels.
fullScreen={isMobile}
> >
<Stack gap="md"> <Stack gap="md">
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">