mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-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 { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
@@ -218,6 +219,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
PaymentSettingsModule,
|
||||
StampSettingsModule,
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ 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,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
@@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ 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);
|
||||
@@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ 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);
|
||||
@@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -943,6 +953,7 @@ describe("BillingService.document", () => {
|
||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.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 { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -201,6 +202,7 @@ export class BillingService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -370,20 +372,24 @@ export class BillingService {
|
||||
const pageSize =
|
||||
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
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.currency) {
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
@@ -465,7 +471,8 @@ export class BillingService {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
@@ -485,6 +492,13 @@ export class BillingService {
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.invoices.findById(invoiceId);
|
||||
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) {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const ledger = new WagonStockLedger(
|
||||
stock.remainingByTypeId,
|
||||
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
|
||||
.filter(
|
||||
({ wagonTypeId }) =>
|
||||
stock.mode !== 'TRAIN' ||
|
||||
!wagonTypeId ||
|
||||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
|
||||
stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
|
||||
)
|
||||
.map(({ wagonTypeId, dims }) => {
|
||||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
||||
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return new WagonStockLedger(
|
||||
stock.remainingByTypeId,
|
||||
Math.max(1, budget.stops.length - 1),
|
||||
stock.byYardId,
|
||||
budget.stops,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
|
||||
`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(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
|
||||
.getRepository(Locomotive)
|
||||
.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
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ currentTrainScheduleId: scheduleId },
|
||||
{ currentYardId: station.yardId },
|
||||
);
|
||||
.createQueryBuilder()
|
||||
.update(Wagon)
|
||||
.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) {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
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) {
|
||||
// Train-bound schedule: the built train's own consist IS the fleet — only
|
||||
// its wagons count (wherever they currently sit; they travel with the
|
||||
// train), and loose yard wagons never do.
|
||||
// its wagons count, and loose yard wagons never do. A single-yard consist
|
||||
// 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 (wagon.trainId !== builtTrainId) continue;
|
||||
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
|
||||
} else {
|
||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||
// 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
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// 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
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
spanFree(w.id),
|
||||
spanFree(w.id) &&
|
||||
(!requiredYardId || w.currentYardId === requiredYardId),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
|
||||
});
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
const byYardId = new Map<string, Map<string, number>>();
|
||||
for (const wagon of wagons) {
|
||||
remainingByTypeId.set(
|
||||
wagon.wagonTypeId,
|
||||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
|
||||
@@ -43,6 +43,16 @@ export type WagonStock = {
|
||||
remainingByTypeId: Map<string, number>;
|
||||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||||
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 = {
|
||||
|
||||
@@ -68,3 +68,94 @@ describe('WagonStockLedger', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util';
|
||||
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
|
||||
*/
|
||||
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[]>();
|
||||
|
||||
constructor(
|
||||
private readonly remainingByTypeId: Map<string, 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. */
|
||||
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
|
||||
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
const row = this.usedPerEdge.get(wagonTypeId);
|
||||
const total = this.totalForType(wagonTypeId, leg);
|
||||
const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
|
||||
if (!row) return total;
|
||||
let busiest = 0;
|
||||
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
||||
@@ -69,10 +105,11 @@ export class WagonStockLedger {
|
||||
if (!deepest) break;
|
||||
|
||||
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) {
|
||||
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) {
|
||||
row[edge] = (row[edge] ?? 0) + take;
|
||||
|
||||
@@ -44,7 +44,7 @@ export class BuildTrainDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
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()
|
||||
@IsArray()
|
||||
|
||||
@@ -283,6 +283,10 @@ export class TrainBuilderService {
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
sequenceNumber: wagon.sequenceNumber,
|
||||
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
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
@@ -326,6 +330,27 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
locomotives,
|
||||
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: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
@@ -428,10 +453,13 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
* yards always match the train's), and each wagon gets a movement-ledger row.
|
||||
* Blocked while the train is out on a dispatched run.
|
||||
* Relocate the train to another yard. The locomotives always follow. Of the
|
||||
* wagons, only those standing WITH the train move: on a consist spread
|
||||
* across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
|
||||
* 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) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -439,6 +467,7 @@ export class TrainBuilderService {
|
||||
if (train.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
const previousYardId = train.currentYardId ?? null;
|
||||
|
||||
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();
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
@@ -475,7 +514,7 @@ export class TrainBuilderService {
|
||||
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) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
@@ -1037,11 +1076,8 @@ export class TrainBuilderService {
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
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`,
|
||||
);
|
||||
}
|
||||
// Wagons may sit in any yard — the schedule's route must pass through
|
||||
// every wagon yard before its destination (checked at scheduling time).
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return [];
|
||||
|
||||
@@ -342,8 +342,8 @@ export class WagonsService {
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon in
|
||||
// the train's own yard can be coupled, and never onto a dispatched train.
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon
|
||||
// (any yard) can be coupled, and never onto a dispatched train.
|
||||
if (wagon.trainId != null) {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
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
|
||||
.createQueryBuilder('w')
|
||||
|
||||
@@ -1471,6 +1471,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:settings:exchange_rate:manage",
|
||||
"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(
|
||||
"b4e00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:contract_templates:view",
|
||||
@@ -2123,6 +2133,14 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
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: {
|
||||
view: "edr_freight_app:settings:contract_templates:view",
|
||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
||||
@@ -2427,6 +2445,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
|
||||
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,
|
||||
// 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
|
||||
|
||||
@@ -86,6 +86,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
|
||||
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
@@ -1156,6 +1157,18 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/manual-payments"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.manualPayment.view}
|
||||
>
|
||||
<div className="p-4">
|
||||
<ManualPaymentSettingsCard />
|
||||
</div>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/exchange-rate"
|
||||
element={
|
||||
|
||||
@@ -562,6 +562,11 @@ export const buildSidebarSections = (
|
||||
href: "/dashboard/configuration/exchange-rate",
|
||||
permission: FREIGHT_PERMS.settings.exchangeRate.view,
|
||||
},
|
||||
{
|
||||
label: "Manual payments",
|
||||
href: "/dashboard/configuration/manual-payments",
|
||||
permission: FREIGHT_PERMS.settings.manualPayment.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,34 +4,40 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MapPin, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* AVAILABLE wagons standing in the train's own yard — the only ones that can
|
||||
* be coupled. Pick any number and append them to the consist.
|
||||
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
|
||||
* so the picker never page-walks the whole fleet into the browser.
|
||||
*/
|
||||
export default function AvailableWagonsPanel({
|
||||
yardId,
|
||||
yardLabel,
|
||||
homeYardId,
|
||||
onAssign,
|
||||
assigning,
|
||||
exportTrainNumber,
|
||||
importTrainNumber,
|
||||
}: AvailableWagonsPanelProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [typeFilter, setTypeFilter] = useState<string>("ALL");
|
||||
const [yardFilter, setYardFilter] = useState<string>("ALL");
|
||||
const [runOnly, setRunOnly] = useState(false);
|
||||
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.
|
||||
const runLabel = exportTrainNumber
|
||||
@@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({
|
||||
: null;
|
||||
|
||||
const wagonsQuery = useQuery(
|
||||
api.wagons.list.queryOptions({
|
||||
api.wagons.listPaged.queryOptions({
|
||||
input: {
|
||||
filters: {
|
||||
status: Freight.WagonStatus.Available,
|
||||
currentYardId: yardId,
|
||||
// Loose wagons only — one already on another train cannot be coupled.
|
||||
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 q = search.trim().toLowerCase();
|
||||
return (wagonsQuery.data ?? []).filter((wagon) => {
|
||||
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 wagons = wagonsQuery.data?.items ?? [];
|
||||
const total = wagonsQuery.data?.meta.total ?? 0;
|
||||
const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
|
||||
|
||||
const runMatchCount = useMemo(
|
||||
() =>
|
||||
exportTrainNumber
|
||||
? (wagonsQuery.data ?? []).filter(
|
||||
(w) => w.exportTrainNumber === exportTrainNumber,
|
||||
).length
|
||||
: 0,
|
||||
[wagonsQuery.data, exportTrainNumber],
|
||||
);
|
||||
// Filters change → back to page 1 (and clamp when the list shrinks).
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, typeFilter, yardFilter, runOnly]);
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const byId = new Map<string, string>();
|
||||
for (const wagon of wagonsQuery.data ?? []) {
|
||||
if (wagon.wagonType) {
|
||||
// e.g. "Flat wagon (NW5)" — name with its type code.
|
||||
byId.set(
|
||||
wagon.wagonType.id,
|
||||
wagon.wagonType.code
|
||||
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
|
||||
: wagon.wagonType.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Dropdowns come from the reference lists, not the current page — a yard or
|
||||
// type must stay pickable even when this page holds none of it.
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
|
||||
a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
|
||||
);
|
||||
return [
|
||||
{ value: "ALL", label: "All types" },
|
||||
...[...byId.entries()].map(([value, label]) => ({ value, label })),
|
||||
{ value: "ALL", label: "All yards" },
|
||||
...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) => {
|
||||
setSelected((prev) =>
|
||||
@@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({
|
||||
);
|
||||
};
|
||||
|
||||
const allSelected =
|
||||
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
|
||||
// Select-all covers this page only — the rest of the matches are not loaded.
|
||||
const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
|
||||
const someSelected = wagons.some((w) => selected.includes(w.id));
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
@@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({
|
||||
onChange={(v) => setTypeFilter(v ?? "ALL")}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
size="sm"
|
||||
leftSection={<MapPin size={14} />}
|
||||
data={yardOptions}
|
||||
value={yardFilter}
|
||||
onChange={(v) => setYardFilter(v ?? "ALL")}
|
||||
searchable
|
||||
aria-label="Filter by yard"
|
||||
/>
|
||||
|
||||
{runLabel ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={`Only wagons on this train's run (${runLabel}) — ${runMatchCount} here`}
|
||||
label={`Only wagons on this train's run (${runLabel})`}
|
||||
checked={runOnly}
|
||||
onChange={(e) => setRunOnly(e.currentTarget.checked)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{wagons.length ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={`Select all (${wagons.length})`}
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && someSelected}
|
||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={`Select all on this page (${wagons.length})`}
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && someSelected}
|
||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||
/>
|
||||
{selected.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{selected.length} selected
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<ScrollArea.Autosize mah={380} type="auto">
|
||||
@@ -166,7 +194,7 @@ export default function AvailableWagonsPanel({
|
||||
</Text>
|
||||
) : !wagons.length ? (
|
||||
<Text py="md" ta="center" c="dimmed" size="sm">
|
||||
No available wagons in {yardLabel ?? "this yard"}
|
||||
No available wagons match
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon) => (
|
||||
@@ -191,6 +219,15 @@ export default function AvailableWagonsPanel({
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</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 ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
@@ -217,6 +254,15 @@ export default function AvailableWagonsPanel({
|
||||
</Stack>
|
||||
</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
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selected.length}
|
||||
@@ -230,8 +276,8 @@ export default function AvailableWagonsPanel({
|
||||
}
|
||||
|
||||
export interface AvailableWagonsPanelProps {
|
||||
yardId: string;
|
||||
yardLabel?: string | null;
|
||||
/** The train's own yard — sorted first and highlighted; not a restriction. */
|
||||
homeYardId: string | null;
|
||||
onAssign: (wagonIds: string[]) => void;
|
||||
assigning: boolean;
|
||||
/** This train's odd EXPORT run — drives the "only this run" filter. */
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
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 { createPortal } from "react-dom";
|
||||
|
||||
@@ -191,6 +191,11 @@ function WagonRow({
|
||||
{wagon.wagonType.code}
|
||||
</Badge>
|
||||
) : 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>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
|
||||
@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/exchange-settings",
|
||||
},
|
||||
|
||||
MANUAL_PAYMENT_SETTINGS: {
|
||||
BASE: "/payment-settings/manual",
|
||||
},
|
||||
|
||||
AUDIT_LOGS: {
|
||||
BASE: "/audit",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -376,6 +376,12 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
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: {
|
||||
view: "edr_freight_app:settings:contract_templates:view",
|
||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
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.
|
||||
*
|
||||
* 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 = [
|
||||
{
|
||||
@@ -30,13 +33,25 @@ const TABS = [
|
||||
},
|
||||
{
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
label: "Manual Payments (ETB)",
|
||||
icon: Banknote,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
/** Hidden unless manual settlement is switched on for this currency. */
|
||||
manualCurrency: "ETB",
|
||||
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.",
|
||||
Panel: UsdPaymentsPanel,
|
||||
"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 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;
|
||||
|
||||
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
|
||||
const { user } = useAuth();
|
||||
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 active: TabKey =
|
||||
visibleTabs.find((tab) => tab.key === requested)?.key ??
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
@@ -144,7 +145,11 @@ function ConfirmCell({
|
||||
* Finance settles by hand; confirming records the payment the same way an
|
||||
* online payment would, so the booking advances identically.
|
||||
*/
|
||||
export default function UsdPaymentsPanel() {
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
|
||||
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(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency: currency || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
);
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
enabled: currencyEnabled,
|
||||
});
|
||||
|
||||
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",
|
||||
header: "Status",
|
||||
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
if (!currencyEnabled) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
[canConfirm, navigate],
|
||||
[canConfirm, currencyEnabled, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
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
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -427,9 +415,11 @@ export default function UsdPaymentsPanel() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: "No invoices awaiting manual payment confirmation."
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
isError
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
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 }}>
|
||||
<Card h="100%">
|
||||
<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">
|
||||
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>
|
||||
<AvailableWagonsPanel
|
||||
yardId={yard?.id ?? ""}
|
||||
yardLabel={yard?.label}
|
||||
homeYardId={yard?.id ?? null}
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -67,6 +67,8 @@ export interface TrainCompositionWagon {
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
status: string;
|
||||
currentYardId: string | null;
|
||||
currentYard: YardRefLite | null;
|
||||
wagonType: {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -77,6 +79,14 @@ export interface TrainCompositionWagon {
|
||||
} | 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 {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
@@ -104,6 +114,7 @@ export interface TrainComposition {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
wagonYards: TrainWagonYardGroup[];
|
||||
totals: TrainCompositionTotals;
|
||||
activeSchedules: ActiveScheduleRef[];
|
||||
editable: boolean;
|
||||
|
||||
@@ -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(
|
||||
api.companies.getInfo.queryOptions({
|
||||
enabled: !!authQuery.data?.id,
|
||||
enabled: !!authQuery.data?.id && isCustomerAccount,
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
|
||||
staleTime(query) {
|
||||
// Fast-poll while anything is awaiting a backoffice decision: an
|
||||
@@ -61,7 +70,7 @@ const useAuth = () => {
|
||||
(p) => p.status !== "active",
|
||||
)
|
||||
)
|
||||
return 60;
|
||||
return 60_000;
|
||||
|
||||
return 10 * 60 * 1000;
|
||||
},
|
||||
@@ -308,7 +317,11 @@ const useAuth = () => {
|
||||
logout,
|
||||
authQuery,
|
||||
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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,15 +6,20 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CloseButton,
|
||||
Drawer,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
PinInput,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
@@ -44,6 +50,9 @@ export default function ContractViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
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 [otpOpen, setOtpOpen] = useState(false);
|
||||
@@ -315,7 +324,7 @@ export default function ContractViewPage() {
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{data.canSignCustomer && hasScrolledToBottom && (
|
||||
{data.canSignCustomer && hasScrolledToBottom && !signOpen && !otpOpen && !successOpen && (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
@@ -325,87 +334,169 @@ export default function ContractViewPage() {
|
||||
maw={920}
|
||||
mx="auto"
|
||||
style={{
|
||||
// Above SupportWidget's Affix (zIndex 300) — the fixed chat FAB
|
||||
// shares this bottom-right corner and would otherwise render on
|
||||
// top of the required agree-and-sign bar.
|
||||
position: "relative",
|
||||
zIndex: 301,
|
||||
// No z-index escalation here: raising this bar above the chat FAB
|
||||
// is what made it fight every overlay on the page. It keeps clear
|
||||
// of the fixed FAB by leaving room for it instead (paddingRight on
|
||||
// wide screens, where the FAB sits beside the bar's right edge).
|
||||
background: "var(--mantine-color-body)",
|
||||
}}
|
||||
pr={{ base: "md", sm: 88 }}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
|
||||
disabled={!hasScrolledToBottom}
|
||||
label={CONSENT_TEXT}
|
||||
description={
|
||||
hasScrolledToBottom
|
||||
? "You may now sign the contract."
|
||||
: "Read the full contract above before you can agree and sign."
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
disabled={!canProceedToSign}
|
||||
onClick={openSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
<Flex
|
||||
direction={{ base: "column", sm: "row" }}
|
||||
align={{ base: "stretch", sm: "center" }}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
|
||||
disabled={!hasScrolledToBottom}
|
||||
label={CONSENT_TEXT}
|
||||
description={
|
||||
hasScrolledToBottom
|
||||
? "You may now sign the contract."
|
||||
: "Read the full contract above before you can agree and sign."
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
disabled={!canProceedToSign}
|
||||
onClick={openSign}
|
||||
fullWidth={isMobile}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</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}
|
||||
onClose={() => setSignOpen(false)}
|
||||
title={usingSaved ? "Approve signature" : "Sign contract"}
|
||||
centered
|
||||
radius="lg"
|
||||
position={isMobile ? "bottom" : "right"}
|
||||
size={isMobile ? "100%" : "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">
|
||||
{data.reference} — your signature is stored securely on the
|
||||
contract.
|
||||
</Text>
|
||||
<>
|
||||
{/* Header — pinned, so the contract reference stays visible while the
|
||||
signature and stamp sections scroll. */}
|
||||
<Group
|
||||
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
|
||||
label="Full name"
|
||||
description="Printed under your signature on the contract."
|
||||
placeholder="Name as it should appear on the contract"
|
||||
withAsterisk
|
||||
value={signerName}
|
||||
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
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
p="sm"
|
||||
style={{
|
||||
borderStyle: "dashed",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
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>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<StampUpload
|
||||
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."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={sendOtpMutation.isPending}
|
||||
disabled={
|
||||
sendOtpMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
Continue to verification
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer — pinned, so the primary action never scrolls out of reach. */}
|
||||
<Stack
|
||||
gap="sm"
|
||||
p="lg"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-body)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldCheck size={14} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
We send a 6-digit code to your registered contacts next.
|
||||
</Text>
|
||||
</Group>
|
||||
<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
|
||||
opened={otpOpen}
|
||||
@@ -440,6 +552,10 @@ export default function ContractViewPage() {
|
||||
title="Verify it's you"
|
||||
centered
|
||||
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">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
|
||||
Reference in New Issue
Block a user