Merge pull request #945 from Tria-plc/dev

Deploy Freight
This commit is contained in:
marshal
2026-07-23 18:06:52 +03:00
committed by GitHub
78 changed files with 3113 additions and 2435 deletions

View File

@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
REEFER: 'Reefer (refrigerated) surcharge', REEFER: 'Reefer (refrigerated) surcharge',
WITH_RETURN: 'Empty-container return service', WITH_RETURN: 'Empty-container return service',
SHIPPING_LINE: 'Shipping line handling', SHIPPING_LINE: 'Shipping line handling',
CONSOLIDATION: 'Container consolidation (extra document)', CONSOLIDATION: 'Penalty (container consolidation)',
LASHING: 'Cargo lashing and securing', LASHING: 'Cargo lashing and securing',
CANCELLATION: 'Booking cancellation fee', CANCELLATION: 'Booking cancellation fee',
DEMURRAGE: 'Demurrage / wagon detention', DEMURRAGE: 'Demurrage / wagon detention',

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customs clearance service fee is no longer prepaid via its own
* `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the
* booking invoice, together with the freight (see BookingPricingService).
*
* - Contracts/bookings parked at the payment gate move straight to the
* document step (the gate no longer exists — nothing could ever pay them).
* - Open (unpaid) clearance invoices are expired; PAID ones stay as history.
* NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but
* has not booked yet will be billed the fee again on its booking invoice —
* accepted for dev data; reverses the old AddClearanceFeePayment migration.
* - clearance_fee_paid_at columns are dropped from contracts and bookings.
*/
export class DropClearanceFeePrepay2860000000000 implements MigrationInterface {
name = 'DropClearanceFeePrepay2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.contracts
SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now()
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.contracts
SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now()
WHERE clearance_status = 'AWAITING_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.bookings
SET status = 'AWAITING_DOCUMENTS', updated_at = now()
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.invoices
SET status = 'EXPIRED', updated_at = now()
WHERE source = 'clearance'
AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE');
`);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Moved rows and expired invoices stay — only the columns come back.
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customs clearance fees are now sold per cargo kind: container fees name a
* container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type
* (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be
* mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the
* base-freight and return-surcharge reshapes, kept readable for snapshot
* history. Per-kind replacements must be re-entered; a customs contract or
* booking without a matching fee hard-blocks. Contracts that already froze a
* FLAT snapshot keep billing it (legacy honoured at booking pricing).
*/
export class CustomsClearancePerKind2870000000000 implements MigrationInterface {
name = 'CustomsClearancePerKind2870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'CUSTOMS_CLEARANCE'
AND rate_unit = 'FLAT';
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Lashing is now sold per cargo kind, like the customs clearance fee:
* container rates name a container type (PER_CONTAINER / PER_WAGON), bulk
* rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape
* cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept
* readable for snapshot history. Per-kind replacements must be re-entered;
* an unconfigured lashing rate simply bills nothing (lenient, like
* hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates
* share the LASHING rate_type and must survive.
*/
export class LashingPerKind2880000000000 implements MigrationInterface {
name = 'LashingPerKind2880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'LASHING'
AND rate_unit = 'FLAT';
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT),
* optionally narrowed to one leaf commodity. Rates that no longer fit —
* container-scoped, or carrying no direction — cannot be mapped and are
* retired (SUPERSEDED + soft-deleted), kept readable for snapshot history.
* Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING).
*/
export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface {
name = 'LashingBulkOnlyPerDirection2890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'LASHING'
AND (container_type_id IS NOT NULL
OR trade_direction IS NULL
OR trade_direction NOT IN ('IMPORT', 'EXPORT'));
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-direction bulk rates instead.
}
}

View File

@@ -16,7 +16,6 @@ import {
InvoiceLineInput, InvoiceLineInput,
} from "../billing/billing.service"; } from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
import { FirstMileService } from "../first-mile/first-mile.service"; import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { PriceLineItemDto } from "./dto/generate-price-response.dto";
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
} }
/** /**
* Expire the booking's currently-open invoices (freight PREPAID and the * Expire the booking's currently-open freight (PREPAID) invoice when the booking is
* per-shipment clearance fee) when the booking is
* cancelled or rejected — the counterpart to the pay-window-expiry path * cancelled or rejected — the counterpart to the pay-window-expiry path
* (which also calls {@link BillingService.expirePayable}). Stops a terminated * (which also calls {@link BillingService.expirePayable}). Stops a terminated
* booking from leaving a payable invoice open. No-op when the booking has no * booking from leaving a payable invoice open. No-op when the booking has no
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
bookingId: string, bookingId: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
// id under its own source/type — retire it alongside the freight invoice, or
// a cancelled shipment keeps a payable clearance invoice open.
await this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
bookingId,
CLEARANCE_BOOKING_INVOICE_TYPE,
manager,
);
return this.billing.expirePayable( return this.billing.expirePayable(
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,
bookingId, bookingId,

View File

@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
ratesService as never, ratesService as never,
exchangeService as never, exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
); );
}); });
@@ -240,3 +241,235 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.blocked[0]).toContain('rate is configured'); expect(result.blocked[0]).toContain('rate is configured');
}); });
}); });
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
const DJ = 'yard-dj';
const containerFee20: Rate = {
id: 'rate-cc-20',
rateType: 'CUSTOMS_CLEARANCE',
trigger: 'CUSTOMS_CLEARANCE',
currency: 'USD',
rateValue: 100,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: 'ct-20',
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE,
} as Rate;
const bulkFeePerTon: Rate = {
...containerFee20,
id: 'rate-cc-bulk',
rateValue: 5,
rateUnit: 'PER_TON',
containerTypeId: null,
} as Rate;
const emptyEval = {
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
};
const makeService = (opts: {
snapshots?: unknown[];
liveRates?: Rate[];
wagonCapacity?: number;
}) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
} as never,
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes:
opts.wagonCapacity !== undefined
? [{ capacityTons: opts.wagonCapacity }]
: [],
}),
} as never,
);
const containerBooking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-cc',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
customsClearingEnabled: true,
originYardId: DJ,
destinationYardId: DIRE,
bookingContainers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
],
...overrides,
}) as unknown as Booking;
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-cc-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
customsClearingEnabled: true,
cargoTypeId: 'cargo-1',
cargoTotalWeightVgm: 120,
originYardId: DJ,
destinationYardId: DIRE,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a container booking per box at its own container type fee', async () => {
const service = makeService({ liveRates: [containerFee20] });
const result = await service.computePriceForBooking(containerBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.unit).toBe('PER_CONTAINER');
expect(line!.quantity).toBe(4);
expect(line!.amount).toBe(400);
});
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
const service = makeService({
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
});
const result = await service.computePriceForBooking(containerBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2);
expect(line!.amount).toBe(200);
});
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking());
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
});
it('bills a bulk booking per ton at the route bulk fee', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('PER_TON');
expect(line!.quantity).toBe(120);
expect(line!.amount).toBe(600);
});
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
const service = makeService({
liveRates: [
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
{
...bulkFeePerTon,
id: 'rate-cc-sugar',
rateValue: 9,
cargoTypeId: 'cargo-1',
} as Rate,
],
});
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
expect(line!.amount).toBe(1080);
});
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
const service = makeService({
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
wagonCapacity: 60,
});
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(100);
});
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
const service = makeService({
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
});
const result = await service.computePriceForBooking(bulkBooking());
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
});
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
const service = makeService({
liveRates: [containerFee20],
snapshots: [
{
rateCode: 'CUSTOMS_CLEARANCE_20FT',
unitPrice: 80,
currency: 'USD',
unitOfMeasure: 'per_container',
isClearance: true,
},
],
});
const result = await service.computePriceForBooking(
containerBooking({ contractId: 'c-1' }),
);
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
});
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
const service = makeService({
liveRates: [],
snapshots: [
{
rateCode: 'CUSTOMS_CLEARANCE',
unitPrice: 500,
currency: 'USD',
unitOfMeasure: 'flat',
isClearance: true,
},
],
});
const result = await service.computePriceForBooking(
containerBooking({ contractId: 'c-legacy' }),
);
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('FLAT');
expect(line!.amount).toBe(500);
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
});
it('adds no fee line when customs clearing is disabled', async () => {
const service = makeService({ liveRates: [containerFee20] });
const result = await service.computePriceForBooking(
containerBooking({ customsClearingEnabled: false }),
);
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
@@ -10,7 +11,10 @@ import {
BookingEvaluationInput, BookingEvaluationInput,
RuleEngineService, RuleEngineService,
} from '../rule-engine/rule-engine.service'; } from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service'; import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -76,6 +80,7 @@ export class BookingPricingService {
private readonly ratesService: RatesService, private readonly ratesService: RatesService,
private readonly exchangeService: ExchangeService, private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService, private readonly containerValidationService: ContainerValidationService,
private readonly cargoTypesService: CargoTypesService,
) {} ) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> { async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -223,6 +228,23 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate); if (rate) usedRatesMap.set(rate.id, rate);
} }
// Customs clearance service fee (Path B) — billed HERE, on the booking
// invoice with the freight; no separate prepaid clearance invoice. Sold per
// cargo kind: container bookings bill each container type's own fee (per
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
// wagon). Frozen contract snapshots win over live rates; a customs booking
// with nothing configured hard-blocks — clearance never ships for free.
const clearanceBlocked: string[] = [];
if (booking.customsClearingEnabled) {
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
for (const line of clearance.lineItems) {
lineItems.push(line);
total += line.amount;
}
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
clearanceBlocked.push(...clearance.blocked);
}
// Overweight detail for the customer: map the engine's per-line results back // Overweight detail for the customer: map the engine's per-line results back
// to the booking's container lines (same order) for code + weights. maxAllowed // to the booking's container lines (same order) for code + weights. maxAllowed
// is derived from the line total minus the excess the engine computed. // is derived from the line total minus the excess the engine computed.
@@ -260,7 +282,7 @@ export class BookingPricingService {
appliedModifiers: ruleResult.appliedModifiers, appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore, priorityScore: ruleResult.priorityScore,
warnings: [...ruleResult.warnings, ...baseWarnings], warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
overweightLines, overweightLines,
}; };
} }
@@ -321,6 +343,8 @@ export class BookingPricingService {
hazardousQuantity: Number(bc.hazardousQuantity ?? 0), hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
reeferQuantity: Number(bc.reeferQuantity ?? 0), reeferQuantity: Number(bc.reeferQuantity ?? 0),
returnQuantity: Number(bc.returnQuantity ?? 0), returnQuantity: Number(bc.returnQuantity ?? 0),
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
}, },
perWagon: containersPerWagonForSize(ct.sizeFt), perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty, quantity: qty,
@@ -338,6 +362,13 @@ export class BookingPricingService {
), ),
) )
: 0; : 0;
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
// Deliberately NOT totalWagons — that would shift wagon-count priority
// scoring for bulk bookings.
const bulkWagons =
booking.freightType === 'BULK'
? ((await this.bulkWagonCount(booking)) ?? 0)
: 0;
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
// a container type leaves a wagon partially filled. Aggregate by type first — // a container type leaves a wagon partially filled. Aggregate by type first —
@@ -386,6 +417,7 @@ export class BookingPricingService {
booking.freightType === 'BULK' booking.freightType === 'BULK'
? Number(booking.cargoTotalWeightVgm ?? 0) ? Number(booking.cargoTotalWeightVgm ?? 0)
: 0, : 0,
bulkWagons,
containers, containers,
}; };
} }
@@ -894,6 +926,191 @@ export class BookingPricingService {
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
} }
/**
* Customs clearance service fee lines for a customs booking (Path B), billed
* with the freight. Container bookings bill each container line at its own
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
* win over live rates; contracts frozen before the per-kind model carry one
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
*/
private async customsClearanceLines(
booking: Booking,
frozenRates: Map<string, ContractRateSnapshot> | null,
liveRates: Rate[],
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
const lineItems: PriceLineItemDto[] = [];
const usedRates: Rate[] = [];
const blocked: string[] = [];
const currency = booking.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` +
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service',
amount,
unitAmount: amount,
unit: 'FLAT',
quantity: 1,
currency,
});
}
return { lineItems, usedRates, blocked };
}
for (const bc of booking.bookingContainers ?? []) {
if (!bc.containerTypeId) continue;
const qty = Number(bc.quantity || 0);
if (!(qty > 0)) continue;
let sizeFt = 0;
try {
sizeFt =
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
} catch {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
continue;
}
const unit = frozen
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
: live!.rateUnit;
const unitAmount = frozen
? Number(frozen.unitPrice)
: convert(Number(live!.rateValue));
const billedQty =
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue;
lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount,
unitAmount,
unit,
quantity: unit === 'FLAT' ? 1 : billedQty,
currency,
});
if (live && !frozen) usedRates.push(live);
}
return { lineItems, usedRates, blocked };
}
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const live =
(booking.cargoTypeId
? onLeg.find(
(r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
)
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!frozen && !live) {
blocked.push(missingRateMessage('bulk cargo'));
return { lineItems, usedRates, blocked };
}
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
let billedQty = 1;
if (unit === 'PER_TON') {
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
} else if (unit === 'PER_WAGON') {
const wagons = await this.bulkWagonCount(booking);
if (wagons == null) {
blocked.push(
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
'no wagon type with a capacity configured — the wagon count cannot ' +
'be derived. Ask EDR to configure the cargo types wagon types.',
);
return { lineItems, usedRates, blocked };
}
billedQty = wagons;
}
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
amount,
unitAmount,
unit,
quantity: unit === 'FLAT' ? 1 : billedQty,
currency,
});
if (live && !frozen) usedRates.push(live);
}
return { lineItems, usedRates, blocked };
}
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
private rateUnitFromSnapshot(unitOfMeasure: string): string {
switch (unitOfMeasure) {
case 'per_wagon':
return 'PER_WAGON';
case 'per_ton':
return 'PER_TON';
case 'per_container':
return 'PER_CONTAINER';
default:
return 'FLAT';
}
}
/**
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
* largest-capacity wagon type its cargo type allows. Null when the chain is
* unconfigured (no cargo type, no wagon types, no capacity).
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
* may stock a smaller type and use more wagons.
*/
private async bulkWagonCount(booking: Booking): Promise<number | null> {
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
if (!(tons > 0) || !booking.cargoTypeId) return null;
try {
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
const capacity = Math.max(
0,
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
);
if (!(capacity > 0)) return null;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;
}
}
private lineItemsSignature(items: PriceLineItemDto[]): string { private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify( return JSON.stringify(
[...items] [...items]

View File

@@ -605,11 +605,6 @@ export class BookingTransitionService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
throw new ConflictException(
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
);
}
assertBookingStatus(booking, [ assertBookingStatus(booking, [
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",

View File

@@ -252,8 +252,11 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId); return this.bookingsService.findAll(filter, companyId);
} }
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
// — otherwise a staffer granted only the customer permission gets a page whose
// tabs 403 individually.
@Get("by-company/:companyId/customer-view") @Get("by-company/:companyId/customer-view")
@BookingView() @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
@ApiOperation({ @ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)", summary: "List bookings for a company (customer-view shape, backoffice)",
}) })

View File

@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'CONTRACT_CLOSED', 'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow). // Post counter-sign document-clearance gate (GL workflow).
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
'AWAITING_DOCUMENTS', 'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW', 'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY', 'CLEARANCE_READY',
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null; clearanceCurrentPhase?: string | null;
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'duty_required', type: 'boolean', nullable: true }) @Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null; dutyRequired?: boolean | null;

View File

@@ -11,13 +11,22 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseInterceptors, UseInterceptors,
UseGuards,
UploadedFiles, UploadedFiles,
BadRequestException, BadRequestException,
NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { BookingStaff } from "../../common/booking-guards";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { FilesService } from "../files/files.service"; import { FilesService } from "../files/files.service";
import { CompaniesService } from "./companies.service"; import { CompaniesService } from "./companies.service";
import { CreateCompanyDto } from "./dto/create-company.dto"; import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -59,6 +68,23 @@ interface CurrentIamUser {
phoneNumber?: string; phoneNumber?: string;
} }
/**
* Which permission a status write needs. Approving/reactivating is a different
* authority from suspending, but both arrive on the same route with the target
* in the BODY — a route-level guard can't tell them apart, so the handlers
* assert against this map instead.
*
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
* (a superset: it adds `rejected`).
*/
const STATUS_PERM: Record<string, string> = {
active: FREIGHT_PERMS.customers.verify,
pending: FREIGHT_PERMS.customers.verify,
rejected: FREIGHT_PERMS.customers.verify,
suspended: FREIGHT_PERMS.customers.deactivate,
blacklisted: FREIGHT_PERMS.customers.deactivate,
};
@ApiTags("Companies") @ApiTags("Companies")
@Controller("companies") @Controller("companies")
export class CompaniesController { export class CompaniesController {
@@ -410,7 +436,7 @@ export class CompaniesController {
// Used by backoffice // Used by backoffice
@Post() @Post()
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.create)
@ApiOperation({ @ApiOperation({
summary: summary:
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
@@ -421,12 +447,14 @@ export class CompaniesController {
} }
@Get("stats") @Get("stats")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Company counts by status (KPI strip)" }) @ApiOperation({ summary: "Company counts by status (KPI strip)" })
async getStats(): Promise<CompanyStatsResponseDto> { async getStats(): Promise<CompanyStatsResponseDto> {
return this.companiesService.getCompanyStats(); return this.companiesService.getCompanyStats();
} }
@Get() @Get()
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List companies (paginated, filterable)" }) @ApiOperation({ summary: "List companies (paginated, filterable)" })
async findAll( async findAll(
@Query() query: ListCompaniesQueryDto, @Query() query: ListCompaniesQueryDto,
@@ -436,6 +464,7 @@ export class CompaniesController {
} }
@Get(":id") @Get(":id")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Get company by ID" }) @ApiOperation({ summary: "Get company by ID" })
async findById( async findById(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -446,30 +475,77 @@ export class CompaniesController {
return dto; return dto;
} }
/**
* Edits fields AND carries `status`, so it spans two authorities. The route
* guard is one-of (a status-only caller must get in); the asserts below are
* what actually authorize: touching `status` needs the permission
* {@link STATUS_PERM} maps it to, touching anything else needs
* `customers:update`. Both checks are required — without the second, a
* caller holding only `customers:deactivate` could rename the company.
*/
@Patch(":id") @Patch(":id")
@FreightAdmin() @BookingStaff([
FREIGHT_PERMS.customers.update,
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
])
@ApiOperation({ summary: "Update a company" }) @ApiOperation({ summary: "Update a company" })
async update( async update(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto, @Body() dto: UpdateCompanyDto,
@CurrentUser() user: TCurrentUser,
): Promise<ResponseCompanyDto> { ): Promise<ResponseCompanyDto> {
const { status, ...fields } = dto;
if (status) assertFreightPermission(user, STATUS_PERM[status]);
if (Object.keys(fields).length > 0) {
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
}
const company = await this.companiesService.updateCompany(id, dto); const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company); return new ResponseCompanyDto(company);
} }
@Delete(":id") @Delete(":id")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.deactivate)
@ApiOperation({ summary: "Soft-delete a company" }) @ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> { async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id); await this.companiesService.deleteCompany(id);
} }
/**
* Dual-audience: staff read any customer's documents, and the portal reads
* its OWN during onboarding (`companiesService.getDocuments`). So the route
* is authenticated-only and the split happens here — same shape as
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
* customer on their own documents.
*
* The staff arm is one-of because two pages consume it: the customer detail
* page (`customers:view`) and the contract-request detail page, whose route
* is gated on `contracts:view` — a contract reviewer without the customer
* permission still needs the applicant's documents.
*/
@Get(":companyId/documents") @Get(":companyId/documents")
@UseGuards(JwtGuard)
@ApiOperation({ summary: "List documents uploaded for a company" }) @ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments( async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@CurrentUser() user: TCurrentUser,
) { ) {
const isStaff = [
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.view,
].some((p) => hasFreightPermission(user, p));
if (!isStaff) {
const { company } = await this.companiesService.getCompanyInfoByUserId(
user.id,
);
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
if (company.id !== companyId) {
throw new NotFoundException(`Company ${companyId} not found`);
}
}
const files = await this.filesService.findByResource(companyId, "companies"); const files = await this.filesService.findByResource(companyId, "companies");
return Promise.all( return Promise.all(
files.map(async (f) => ({ files.map(async (f) => ({
@@ -490,7 +566,7 @@ export class CompaniesController {
} }
@Post("documents/:fileId/request-change") @Post("documents/:fileId/request-change")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({ @ApiOperation({
summary: "Ask the customer to correct one uploaded document", summary: "Ask the customer to correct one uploaded document",
description: description:
@@ -532,14 +608,23 @@ export class CompaniesController {
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
} }
/**
* Approve / reject / suspend / blacklist all arrive here with the target in
* the body, so authorization is per-status via {@link STATUS_PERM} rather
* than on the route (the guard is only the one-of gate).
*/
@Patch("company-profiles/:profileId/status") @Patch("company-profiles/:profileId/status")
@FreightAdmin() @BookingStaff([
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
])
@ApiOperation({ summary: "Update a company profile's approval status" }) @ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus( async updateCompanyProfileStatus(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: TCurrentUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto, @Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> { ): Promise<ResponseCompanyProfileDto> {
assertFreightPermission(user, STATUS_PERM[dto.status]);
const profile = await this.companiesService.setCompanyProfileStatus( const profile = await this.companiesService.setCompanyProfileStatus(
profileId, profileId,
dto.status, dto.status,
@@ -550,7 +635,7 @@ export class CompaniesController {
} }
@Get(":companyId/change-requests") @Get(":companyId/change-requests")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List a company's profile change requests" }) @ApiOperation({ summary: "List a company's profile change requests" })
async listChangeRequests( async listChangeRequests(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@@ -560,7 +645,7 @@ export class CompaniesController {
} }
@Post("change-requests/:id/approve") @Post("change-requests/:id/approve")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({ @ApiOperation({
summary: "Approve a pending profile change request (applies the changes)", summary: "Approve a pending profile change request (applies the changes)",
}) })
@@ -576,7 +661,7 @@ export class CompaniesController {
} }
@Post("change-requests/:id/reject") @Post("change-requests/:id/reject")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({ @ApiOperation({
summary: "Reject a pending profile change request with a note", summary: "Reject a pending profile change request with a note",
}) })
@@ -594,7 +679,7 @@ export class CompaniesController {
} }
@Post(":companyId/profiles") @Post(":companyId/profiles")
@FreightAdmin() @BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" }) @ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile( async createProfile(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@@ -608,6 +693,7 @@ export class CompaniesController {
} }
@Get(":companyId/profiles") @Get(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List profiles for a company" }) @ApiOperation({ summary: "List profiles for a company" })
async listProfiles( async listProfiles(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@@ -618,6 +704,7 @@ export class CompaniesController {
} }
@Get("profile/user/:userId") @Get("profile/user/:userId")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Get profile by IAM user ID" }) @ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser( async findProfileByUser(
@Param("userId", ParseUUIDPipe) userId: string, @Param("userId", ParseUUIDPipe) userId: string,

View File

@@ -1,239 +0,0 @@
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { ContractPricingBreakdown } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
/**
* The prepaid customs clearance service fee (Path B) — the GL service charge,
* separate from both freight (booking invoice) and duty/tax (paid offline).
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
* document step opens and before GL touches the file:
* - ONE_TIME: once per contract, at staff counter-sign
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
* - GENERAL: once per shipment request, on the initiated booking instance
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
* customers pay what their contract shows, not the live rate of the day.
*/
@Injectable()
export class ClearanceFeeService {
private readonly logger = new Logger(ClearanceFeeService.name);
constructor(
private readonly billing: BillingService,
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: ContractNotifierService,
) {}
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
private async feeAmountOrNull(
contract: Contract,
): Promise<{ amount: number; currency: string } | null> {
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
const snapshot = snapshots.find(
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
);
if (snapshot && Number(snapshot.unitPrice) > 0) {
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
}
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
if (line && Number(line.unitPrice) > 0) {
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
}
return null;
}
private async feeAmount(
contract: Contract,
): Promise<{ amount: number; currency: string }> {
const fee = await this.feeAmountOrNull(contract);
if (!fee) {
throw new UnprocessableEntityException(
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
);
}
return fee;
}
/**
* Whether the payment gate applies. Skipped for government/unlinked
* contracts (no company to bill — invoices require one, same rule the
* booking invoice applies) and for legacy customs contracts frozen before
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
* the pre-fee flow instead of dead-ending.
*/
async gateApplies(contract: Contract): Promise<boolean> {
// Customs disabled → the prepay gate genuinely does not apply.
if (!contract.customsClearingEnabled) return false;
// No company to bill (government / unlinked) → the gate cannot raise an
// invoice, so it stays out of the flow (same rule the booking invoice uses).
if (!contract.companyId) return false;
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
// silently waive the gate — that ships clearance for free. Hard-fail exactly
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
// missing fee blocks counter-sign / shipment instead of bypassing payment.
if ((await this.feeAmountOrNull(contract)) === null) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
);
}
return true;
}
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
async issueForContract(contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
contract.id,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: contract.id,
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
companyId: contract.companyId!,
companyProfileId: contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — contract ${contract.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency);
return invoice;
}
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
booking.id,
CLEARANCE_BOOKING_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: booking.id,
type: CLEARANCE_BOOKING_INVOICE_TYPE,
companyId: booking.companyId ?? contract.companyId!,
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — shipment ${booking.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
return invoice;
}
/**
* Retire (idempotently) the unpaid contract-level fee invoice when the
* contract reaches a terminal state — a dead contract must not leave a
* payable clearance invoice open for the customer to settle. No-op when the
* fee was already paid or never invoiced (mirrors the booking cancel path,
* {@link BillingService.expirePayable}).
*/
async expireForContract(contractId: string): Promise<Invoice | null> {
return this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
contractId,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
}
/**
* Settlement branch point for `clearance`-source invoices: unlock the
* document-upload step the fee was gating. Idempotent — a replayed event on
* an already-advanced contract/booking is a no-op.
*/
@OnEvent('clearance.invoice.paid')
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
this.logger.log(
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
);
switch (payload.type) {
case CLEARANCE_CONTRACT_INVOICE_TYPE:
await this.advanceContract(payload.sourceId);
break;
case CLEARANCE_BOOKING_INVOICE_TYPE:
await this.advanceBooking(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
private async advanceContract(contractId: string): Promise<void> {
const contract = await this.contractsRepository.findById(contractId);
if (!contract) {
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
return;
}
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
if (updated) this.notifier.clearanceFeePaid(updated);
}
private async advanceBooking(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
return;
}
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.bookingsRepository.update(bookingId, {
status: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
if (booking.contractId) {
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
}
}
}

View File

@@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // milestoneService {} as never, // milestoneService
{} as never, // workflowService {} as never, // workflowService
{} as never, // invoiceService {} as never, // invoiceService
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier { createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService

View File

@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
milestoneService as never, milestoneService as never,
{} as never, // workflowService {} as never, // workflowService
invoiceService as never, invoiceService as never,
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier { createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService

View File

@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity'; import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceWorkflowService } from './clearance-workflow.service';
import { import {
@@ -97,7 +96,6 @@ export class ContractBookingService {
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService, private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService, private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService)) @Inject(forwardRef(() => TrainSchedulingService))
@@ -537,11 +535,8 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, opts.contractRouteId); const route = await this.resolveRoute(contract, opts.contractRouteId);
// Prepay gate: each shipment request owes its own flat clearance service // No prepay gate: the clearance service fee is billed on the booking
// fee before the document step opens (the paid event advances the booking // invoice at completion, so the document step opens immediately.
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
const feeGate = await this.clearanceFeeService.gateApplies(contract);
const booking = await insertWithGeneratedReference( const booking = await insertWithGeneratedReference(
() => this.generateReference(), () => this.generateReference(),
(reference) => (reference) =>
@@ -551,7 +546,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null, companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment, isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null, governmentInstitution: contract.governmentInstitution ?? null,
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS', status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME', bookingType: 'ONE_TIME',
contractId: contract.id, contractId: contract.id,
contractRouteId: route?.id ?? null, contractRouteId: route?.id ?? null,
@@ -590,10 +585,6 @@ export class ContractBookingService {
contract.tradeDirection, contract.tradeDirection,
); );
if (feeGate) {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
const created = const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created); this.bookingNotifier.createdToStaff(created);

View File

@@ -499,11 +499,6 @@ export class ContractClearanceService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
throw new ConflictException(
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
);
}
if ( if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW' contract.status !== 'CLEARANCE_UNDER_REVIEW'

View File

@@ -178,26 +178,6 @@ export class ContractNotifierService {
}); });
} }
/** Clearance service fee invoiced — customer must pay before document upload. */
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
`Please pay from the portal to unlock the clearance document upload.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
this.inApp(c, 'Clearance fee due', msg);
}
/** Clearance service fee settled — document upload is now open. */
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`Your customs clearance service fee for ${scope} has been received. ` +
`You can now upload the clearance documents from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
this.inApp(c, 'Clearance fee paid', msg);
}
// ── Clearance milestones needing customer action ────────────────────────── // ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity';
export interface ContractUnitRateLineItem { export interface ContractUnitRateLineItem {
code: string; code: string;
label: string; label: string;
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unitPrice: number; unitPrice: number;
containerSize?: string | null; containerSize?: string | null;
conditionalOn?: string | null; conditionalOn?: string | null;
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
return 'per_ton'; return 'per_ton';
case 'PER_KM': case 'PER_KM':
return 'per_km'; return 'per_km';
case 'PER_CONTAINER':
case 'PER_WAGON': case 'PER_WAGON':
return 'per_wagon';
case 'PER_CONTAINER':
return 'per_container'; return 'per_container';
default: default:
return 'flat'; return 'flat';
@@ -188,6 +189,36 @@ export class ContractPricingService {
}); });
} }
} }
// Lashing / cargo securing — BULK only, shown when the contract's commodity
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
// contract's direction wins over the commodity-wide catch-all; billed at
// booking on the live rate (per ton / per wagon), this line is display.
if (contract.freightType === 'BULK') {
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasLashing) {
const onDirection = liveRates.filter(
(r) =>
r.trigger === 'LASHING' &&
r.currency === 'USD' &&
!r.containerTypeId &&
r.tradeDirection === contract.tradeDirection,
);
const lashing =
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
onDirection.find((r) => !r.cargoTypeId);
if (lashing && Number(lashing.rateValue) > 0) {
lineItems.push({
code: 'LASHING',
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
unit: toContractUnit(lashing.rateUnit),
unitPrice: convert(Number(lashing.rateValue)),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_lashing',
});
}
}
}
// Empty-container return service — container contracts only, toggled on the // Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container. // contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if ( if (
@@ -240,18 +271,19 @@ export class ContractPricingService {
} }
} }
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the // Customs clearance service fee (Path B) — billed on the booking invoice
// contract and billed via its own clearance invoice: after counter-sign for // together with the freight. Sold per direction + route + cargo kind:
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. // container contracts freeze one fee line per contract size (each size's
// A customs contract may not proceed without a configured live rate. // own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) { if (contract.customsClearingEnabled) {
// The fee is sold per direction + route — strict, no route-less fallback. // Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const route = [...(contract.routes ?? [])].sort( const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder, (a, b) => a.sortOrder - b.sortOrder,
)[0]; )[0];
const clearance = route const onLeg = route
? liveRates.find( ? liveRates.filter(
(r) => (r) =>
r.rateType === 'CUSTOMS_CLEARANCE' && r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' && r.currency === 'USD' &&
@@ -259,22 +291,63 @@ export class ContractPricingService {
r.originYardId === route.originYardId && r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId, r.destinationYardId === route.destinationYardId,
) )
: undefined; : [];
if (!clearance || Number(clearance.rateValue) <= 0) { if (contract.freightType === 'CONTAINER') {
throw new UnprocessableEntityException( const sizes = (contract.cargoScope ?? [])
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.', .map((c) => c.containerSize)
); .filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate = onLeg.find(
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
isClearance: true,
});
}
} else {
// Bulk fee — the rate scoped to the contract's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
const rate =
(scope?.cargoTypeId
? onLeg.find(
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
)
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,
isClearance: true,
});
} }
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label:
contract.contractKind === 'GENERAL'
? 'Customs clearance service fee (per shipment request, prepaid)'
: 'Customs clearance service fee (prepaid)',
unit: toContractUnit(clearance.rateUnit),
unitPrice: convert(Number(clearance.rateValue)),
isClearance: true,
});
} }
return { return {

View File

@@ -36,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service'; import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
@@ -162,7 +161,6 @@ export class ContractTransitionService {
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService, private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
@InjectDataSource() @InjectDataSource()
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
@@ -591,10 +589,6 @@ export class ContractTransitionService {
actorId, actorId,
'STAFF', 'STAFF',
); );
// Stop the open-invoice leak: a rejected contract must not leave a payable
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
await this.clearanceFeeService.expireForContract(contractId);
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'REJECTED', status: 'REJECTED',
} as never); } as never);
@@ -654,10 +648,6 @@ export class ContractTransitionService {
'STAFF', 'STAFF',
); );
// Stop the open-invoice leak: a rejected contract must not leave a payable
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
await this.clearanceFeeService.expireForContract(contractId);
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'REJECTED', status: 'REJECTED',
} as never); } as never);
@@ -669,9 +659,9 @@ export class ContractTransitionService {
/** /**
* Internal send-back branch of rejectStep: return the contract to an earlier, * Internal send-back branch of rejectStep: return the contract to an earlier,
* already-approved stage of the chain instead of rejecting it outright. * already-approved stage of the chain instead of rejecting it outright.
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract * Deliberately NOT the terminal path: the contract is still alive and there
* is still alive) and no customer-facing REJECTION note — the trail is a * is no customer-facing REJECTION note — the trail is a staff note plus a
* staff note plus a backoffice inbox ping. * backoffice inbox ping.
*/ */
private async sendBackToStep( private async sendBackToStep(
contract: Contract, contract: Contract,
@@ -1169,17 +1159,11 @@ export class ContractTransitionService {
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// Path B prepay gate: the customs clearance service fee is invoiced here // No prepay gate: the customs clearance service fee (Path B) is billed on
// and must settle before the document step opens (the paid event advances // the booking invoice together with the freight, so the document step
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee. // opens immediately.
if (await this.clearanceFeeService.gateApplies(contract)) { updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
await this.clearanceFeeService.issueForContract(contract); updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
updates.clearanceStatus = 'AWAITING_PAYMENT';
} else {
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
}
updates.clearanceCycleNumber = cycleNumber; updates.clearanceCycleNumber = cycleNumber;
} else { } else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract // No contract-level clearance gate — DOMESTIC, or any GENERAL contract

View File

@@ -361,8 +361,15 @@ export class ContractsController {
); );
} }
// Readable by anyone who may view the contract: the draft carries
// `editableByMe`, and the approval chain's approvers (identified by position
// type, not by staff_accept) must be able to fetch it to learn it is their
// turn. Gating this on staff_accept hid the edit dialog from every approver.
@Get(':id/document/draft') @Get(':id/document/draft')
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@ApiOperation({ @ApiOperation({
summary: summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
@@ -385,8 +392,15 @@ export class ContractsController {
return this.documentHistory.list(id); return this.documentHistory.list(id);
} }
// Coarse gate only. WHO may actually edit is turn-based, not a static
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
// real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval).
@Put(':id/document/articles') @Put(':id/document/articles')
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@ApiOperation({ @ApiOperation({
summary: summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',

View File

@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service'; import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService, ContractsService,
ContractsRepository, ContractsRepository,
ContractPricingService, ContractPricingService,
ClearanceFeeService,
ContractNotifierService, ContractNotifierService,
ContractTransitionService, ContractTransitionService,
ContractDocumentHistoryService, ContractDocumentHistoryService,

View File

@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
conditionalOn?: string | null; conditionalOn?: string | null;
/** /**
* Customs clearance service fee line — billed up front via a clearance * Customs clearance service fee line — billed on the booking invoice
* invoice, excluded from shipment booking totals. * together with the freight (no separate prepaid clearance invoice).
*/ */
@Column({ name: 'is_clearance', type: 'boolean', default: false }) @Column({ name: 'is_clearance', type: 'boolean', default: false })
isClearance!: boolean; isClearance!: boolean;

View File

@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
'SIGNED_CUSTOMER', 'SIGNED_CUSTOMER',
'FULLY_EXECUTED', 'FULLY_EXECUTED',
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
'AWAITING_CLEARANCE_DOCUMENTS', 'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', 'CLEARANCE_READY_FOR_BOOKING',
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [ export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE', 'NOT_APPLICABLE',
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
'AWAITING_DOCUMENTS', 'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW', 'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number; clearanceCycleNumber!: number;
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null; pricingBreakdown?: Record<string, unknown> | null;

View File

@@ -16,7 +16,8 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Response } from "express"; import { Response } from "express";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
import { BookingView } from "../../common/booking-guards"; import { BookingStaff, BookingView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { PaymentService } from "./payment.service"; import { PaymentService } from "./payment.service";
import { IntentStatusDto } from "./payments.dto"; import { IntentStatusDto } from "./payments.dto";
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
export class PaymentController { export class PaymentController {
constructor(private readonly paymentService: PaymentService) { } constructor(private readonly paymentService: PaymentService) { }
// Customer-detail payments tab — same one-of rule as the bookings tab.
@Get("by-company/:companyId/customer-view") @Get("by-company/:companyId/customer-view")
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
findByCompanyCustomerView( findByCompanyCustomerView(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,

View File

@@ -10,6 +10,7 @@ import {
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const; const CURRENCIES = ['USD'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
export class CreateRateDto { export class CreateRateDto {
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
@@ -47,6 +48,15 @@ export class CreateRateDto {
@IsIn([...INTERCITY_KINDS]) @IsIn([...INTERCITY_KINDS])
intercityKind?: string; intercityKind?: string;
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])
cargoKind?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',

View File

@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
export function allowedRateUnits(input: { export function allowedRateUnits(input: {
appliesTo: RateAppliesTo; appliesTo: RateAppliesTo;
trigger: RateTrigger; trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] { }): RateUnit[] {
const { appliesTo, trigger } = input; const { appliesTo, trigger } = input;
@@ -29,16 +31,20 @@ export function allowedRateUnits(input: {
case 'DEMURRAGE': case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON']; return ['PER_CONTAINER', 'PER_TON'];
case 'WITH_RETURN': case 'WITH_RETURN':
// Container-only empty-return service — bills per returned container. // Container-only empty-return service — per returned container, per
return ['PER_CONTAINER', 'FLAT']; // wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION': case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE']; return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE': case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). // Sold per cargo kind: container fees bill per box or per wagon, bulk
return ['FLAT']; // fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'
? ['PER_TON', 'PER_WAGON']
: ['PER_CONTAINER', 'PER_WAGON'];
case 'LASHING': case 'LASHING':
// Flat cargo-securing fee, billed once per booking. // Bulk-only cargo securing — per ton or per wagon.
return ['FLAT']; return ['PER_TON', 'PER_WAGON'];
case 'CONSOLIDATION': case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT']; return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE': case 'SHIPPING_LINE':
@@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
export function isRateUnitAllowed(input: { export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo; appliesTo: RateAppliesTo;
trigger: RateTrigger; trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
unit: RateUnit; unit: RateUnit;
}): boolean { }): boolean {
return allowedRateUnits(input).includes(input.unit); return allowedRateUnits(input).includes(input.unit);

View File

@@ -249,6 +249,49 @@ describe('RuleEngineService — empty-container return per route + container typ
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
}); });
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
// Same service, but the return rate is sold per wagon: 4× 20ft return =
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest
.fn()
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(
returnInput({
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 4,
wagonsPerUnit: 0.5,
},
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_WAGON');
});
it('legacy booking-level flag bills every container at its type rate', async () => { it('legacy booking-level flag bills every container at its type rate', async () => {
const result = await service.evaluate( const result = await service.evaluate(
returnInput({ returnInput({
@@ -264,3 +307,118 @@ describe('RuleEngineService — empty-container return per route + container typ
expect(ret[0].calculatedAmount).toBe(80); expect(ret[0].calculatedAmount).toBe(80);
}); });
}); });
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
const lashingBulkImport: Rate = {
id: 'rate-lash-bulk',
rateType: 'LASHING',
trigger: 'LASHING',
rateValue: 2,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: null,
destinationYardId: null,
} as Rate;
const buildService = (rates: Rate[]): RuleEngineService =>
new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
cargoTypeId: 'cargo-sugar',
totalWagons: 0,
bulkTons: 100,
bulkWagons: 3,
containers: [],
...overrides,
});
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
it('bulk lashing bills per ton on the direction-matched rate', async () => {
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(100);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_TON');
});
it('a rate for the other direction never bills', async () => {
const result = await buildService([
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
]).evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
const result = await buildService([
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(3);
expect(mods[0].calculatedAmount).toBe(75);
});
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
const result = await buildService([
lashingBulkImport,
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].unitPriceUsd).toBe(7);
expect(mods[0].calculatedAmount).toBe(700);
});
it('container bookings never incur lashing (bulk-only service)', async () => {
const result = await buildService([lashingBulkImport]).evaluate(
bulkInput({
cargoTypeId: null,
hasLashing: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
);
expect(lashingMods(result)).toHaveLength(0);
});
it('no lashing charge when the cargo does not need lashing', async () => {
const service = new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
});

View File

@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
hazardousQuantity?: number; hazardousQuantity?: number;
reeferQuantity?: number; reeferQuantity?: number;
returnQuantity?: number; returnQuantity?: number;
/**
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
* ride back on. Missing ⇒ one wagon per container.
*/
wagonsPerUnit?: number;
} }
export interface BookingEvaluationInput { export interface BookingEvaluationInput {
@@ -86,6 +92,12 @@ export interface BookingEvaluationInput {
* container freight, which is scaled by container count instead. * container freight, which is scaled by container count instead.
*/ */
bulkTons?: number; bulkTons?: number;
/**
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
* 0/undefined when unknown — those charges then bill nothing.
*/
bulkWagons?: number;
containers: BookingContainerEvalInput[]; containers: BookingContainerEvalInput[];
} }
@@ -312,6 +324,9 @@ export class RuleEngineService {
// Empty-container return is sold per route + container type — billed by // Empty-container return is sold per route + container type — billed by
// the route-matched block below, never by this route-agnostic loop. // the route-matched block below, never by this route-agnostic loop.
if (rate.trigger === 'WITH_RETURN') continue; if (rate.trigger === 'WITH_RETURN') continue;
// Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue;
const triggered = this.matchesTrigger(rate.trigger, { const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous, isHazardous: input.isHazardous,
hasReefer, hasReefer,
@@ -419,6 +434,10 @@ export class RuleEngineService {
appliedModifiers.push(...withReturn.modifiers); appliedModifiers.push(...withReturn.modifiers);
hardBlocked.push(...withReturn.blocked); hardBlocked.push(...withReturn.blocked);
if (hasLashing) {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
return { return {
priorityScore, priorityScore,
appliedModifiers, appliedModifiers,
@@ -538,12 +557,18 @@ export class RuleEngineService {
} }
const rateValue = Number(rate.rateValue); const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue; // PER_WAGON bills the wagons the returned empties occupy (two 20ft share
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
const billed =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
if (!(amount > 0)) continue; if (!(amount > 0)) continue;
modifiers.push({ modifiers.push({
rateId: rate.id, rateId: rate.id,
surchargeCode: this.surchargeCode(rate), surchargeCode: this.surchargeCode(rate),
triggerValue: qty, triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
calculatedAmount: amount, calculatedAmount: amount,
currency: rate.currency, currency: rate.currency,
unitPriceUsd: rateValue, unitPriceUsd: rateValue,
@@ -555,6 +580,54 @@ export class RuleEngineService {
return { modifiers, blocked: [...new Set(blocked)] }; return { modifiers, blocked: [...new Set(blocked)] };
} }
/**
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
* wagons the bulk occupies. Container bookings never incur lashing, and an
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
*/
private lashingCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
if (input.containers.length > 0) return modifiers; // bulk-only service
const onDirection = liveRates.filter(
(r) =>
r.trigger === 'LASHING' &&
r.currency === 'USD' &&
!r.containerTypeId &&
r.tradeDirection === input.tradeDirection,
);
const rate =
(input.cargoTypeId
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
if (!rate) return modifiers;
const billedQty =
rate.rateUnit === 'PER_TON'
? Math.max(0, Number(input.bulkTons ?? 0))
: rate.rateUnit === 'PER_WAGON'
? Math.max(0, Number(input.bulkWagons ?? 0))
: 1;
const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
if (!(amount > 0)) return modifiers;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
return modifiers;
}
/** /**
* Messages for container lines whose total weight exceeds the hard capacity * Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ rateValue: 200 }); expect(request.payload).toEqual({ rateValue: 200 });
}); });
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
const { service } = build({
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
});
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 100,
originYardId: 'yard-a',
destinationYardId: 'yard-c',
},
});
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build(); const { service } = build();
await expect( await expect(

View File

@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
'tradeDirection', 'tradeDirection',
'containerTypeId', 'containerTypeId',
'cargoTypeId', 'cargoTypeId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
] as const; ] as const;
/** /**

View File

@@ -69,18 +69,19 @@ export class RatesService {
appliesTo: Rate['appliesTo'], appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'], trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'] | undefined, requestedUnit: Rate['rateUnit'] | undefined,
cargoKind?: 'CONTAINER' | 'BULK' | null,
): Rate['rateUnit'] { ): Rate['rateUnit'] {
// Overweight is per-ton, full stop — the admin form hides the unit field // Overweight is per-ton, full stop — the admin form hides the unit field
// for it and omits rateUnit from the payload entirely. // for it and omits rateUnit from the payload entirely.
if (trigger === 'OVERWEIGHT') return 'PER_TON'; if (trigger === 'OVERWEIGHT') return 'PER_TON';
const allowed = allowedRateUnits({ appliesTo, trigger }); const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
if (!requestedUnit) { if (!requestedUnit) {
throw new BadRequestException( throw new BadRequestException(
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
); );
} }
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
throw new BadRequestException( throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
); );
@@ -187,10 +188,11 @@ export class RatesService {
trigger: Rate['trigger']; trigger: Rate['trigger'];
tradeDirection: string | null; tradeDirection: string | null;
intercityKind: string | null; intercityKind: string | null;
cargoKind: string | null;
containerTypeId: string | null; containerTypeId: string | null;
cargoTypeId: string | null; cargoTypeId: string | null;
}): void { }): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input; const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input; const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') { if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
@@ -198,6 +200,51 @@ export class RatesService {
'A customs clearance rate must say whether it covers IMPORT or EXPORT.', 'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
); );
} }
// Sold per cargo kind: a container fee names the container type it covers
// (20ft and 40ft price differently); a bulk fee carries no type at all —
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers containers or bulk.',
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
'A container customs clearance rate must name the container type it covers.',
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate cannot be scoped to a container type.',
);
}
// The bulk customs fee names the commodity it covers (sugar and
// fertilizer clear differently).
if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.',
);
}
if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
);
}
return;
}
if (trigger === 'LASHING') {
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all).
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A lashing rate must say whether it covers IMPORT or EXPORT.',
);
}
if (containerTypeId) {
throw new BadRequestException(
'Lashing is bulk-only — it cannot be scoped to a container type.',
);
}
return; return;
} }
if (trigger === 'WITH_RETURN') { if (trigger === 'WITH_RETURN') {
@@ -279,20 +326,31 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger']; const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction. // the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container // Exceptions: customs clearance and empty-container return keep direction +
// return keeps direction + container type — both are sold per lane. // container type — both are sold per lane (and per container type).
const isSurcharge = trigger !== 'ALWAYS'; const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId = const containerTypeId =
trigger === 'WITH_RETURN' trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null) ? (dto.containerTypeId ?? null)
: isSurcharge : isSurcharge
? null ? null
: (dto.containerTypeId ?? null); : (dto.containerTypeId ?? null);
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING'
? (dto.cargoTypeId ?? null)
: isSurcharge
? null
: (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store — // Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs. // its yard pair already says where it runs.
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
? (dto.tradeDirection ?? null) ? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY' : isSurcharge || appliesTo === 'INTERCITY'
? null ? null
@@ -304,6 +362,7 @@ export class RatesService {
trigger, trigger,
tradeDirection, tradeDirection,
intercityKind, intercityKind,
cargoKind,
containerTypeId, containerTypeId,
cargoTypeId, cargoTypeId,
}); });
@@ -325,6 +384,7 @@ export class RatesService {
appliesTo, appliesTo,
trigger, trigger,
dto.rateUnit as Rate['rateUnit'] | undefined, dto.rateUnit as Rate['rateUnit'] | undefined,
cargoKind,
); );
await this.assertNoDuplicatePattern({ await this.assertNoDuplicatePattern({
@@ -419,19 +479,34 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger; if (dto.trigger) updates.trigger = trigger;
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN'; // A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType const containerTypeId = !keepsContainerType
? null ? null
: dto.containerTypeId !== undefined : dto.containerTypeId !== undefined
? dto.containerTypeId ? dto.containerTypeId
: existing.containerTypeId; : existing.containerTypeId;
const cargoTypeId = isSurcharge const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING';
const cargoTypeId = !keepsCargoType
? null ? null
: dto.cargoTypeId !== undefined : dto.cargoTypeId !== undefined
? dto.cargoTypeId ? dto.cargoTypeId
: existing.cargoTypeId; : existing.cargoTypeId;
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
? dto.tradeDirection !== undefined ? dto.tradeDirection !== undefined
? dto.tradeDirection ? dto.tradeDirection
: existing.tradeDirection : existing.tradeDirection
@@ -455,6 +530,7 @@ export class RatesService {
trigger, trigger,
tradeDirection: updates.tradeDirection, tradeDirection: updates.tradeDirection,
intercityKind, intercityKind,
cargoKind,
containerTypeId: updates.containerTypeId, containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId, cargoTypeId: updates.cargoTypeId,
}); });
@@ -486,7 +562,7 @@ export class RatesService {
// Re-validate the unit against the (possibly changed) shape; overweight is // Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON. // forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
// Guard the pattern uniqueness for the new identity, ignoring this row. // Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({ await this.assertNoDuplicatePattern({

View File

@@ -416,9 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
// Cargo-securing / lashing — flat fee, billed once per booking whose // Cargo-securing / lashing — bulk-only, fires when the cargo type has
// cargo type has hasLashing = true. // hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" }, // commodity-specific rates are configured by the rates team.
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
// ── First/last-mile road haulage (per km) — drives the mile invoices ── // ── First/last-mile road haulage (per km) — drives the mile invoices ──
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },

View File

@@ -802,8 +802,22 @@ const App = () => {
} }
/> />
<Route path="support" element={<SupportInboxPage />} /> <Route path="support" element={<SupportInboxPage />} />
<Route path="customers" element={<CustomersPage />} /> <Route
<Route path="customers/:id" element={<CustomerDetailPage />} /> path="customers"
element={
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
<CustomersPage />
</RequirePermission>
}
/>
<Route
path="customers/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
<CustomerDetailPage />
</RequirePermission>
}
/>
<Route <Route
path="invoices" path="invoices"
element={ element={

View File

@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
(i) => (i) =>
i.containerSize === line.containerSize && i.containerSize === line.containerSize &&
i.unit === "per_container" && i.unit === "per_container" &&
!i.conditionalOn, !i.conditionalOn &&
) ?? rateFor((i) => i.containerSize === line.containerSize); !i.isClearance,
) ??
rateFor(
(i) => i.containerSize === line.containerSize && !i.isClearance,
);
if (rate) { if (rate) {
lines.push({ lines.push({
label: rate.label, label: rate.label,
@@ -123,7 +127,12 @@ export function computeGlShipmentTotal(
} else { } else {
const qty = q.bulkQuantity; const qty = q.bulkQuantity;
const rate = const rate =
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; rateFor(
(i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
if (rate && qty > 0) { if (rate && qty > 0) {
lines.push({ lines.push({
label: rate.label, label: rate.label,
@@ -159,6 +168,53 @@ export function computeGlShipmentTotal(
} }
} }
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
// the wagon capacity the train stocks — shown at real pricing, not estimated.
for (const cl of items.filter((i) => i.isClearance)) {
let qty = 0;
if (q.isContainer) {
const boxes = q.containers
.filter((c) => c.containerSize === cl.containerSize)
.reduce((s, c) => s + Number(c.quantity || 0), 0);
qty =
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
qty = q.bulkQuantity;
} else if (cl.unit === "flat") {
qty = 1;
}
if (qty > 0) {
lines.push({
label: cl.label,
unitPrice: cl.unitPrice,
unit: cl.unit,
quantity: qty,
amount: cl.unitPrice * qty,
});
}
}
const total = lines.reduce((s, l) => s + l.amount, 0); const total = lines.reduce((s, l) => s + l.amount, 0);
return { currency, lines, total }; return { currency, lines, total };
} }
@@ -167,6 +223,7 @@ export function computeGlShipmentTotal(
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
per_container: "container", per_container: "container",
per_wagon: "wagon",
per_ton: "ton", per_ton: "ton",
per_item: "item", per_item: "item",
per_km: "km", per_km: "km",

View File

@@ -23,6 +23,8 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useFileViewer } from "@edr/ui-common"; import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service"; import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer"; import type { Company, CompanyChangeRequest } from "@/types/customer";
@@ -128,6 +130,8 @@ function DiffRow({
* (with note) actions, plus a short history of past decisions. * (with note) actions, plus a short history of past decisions.
*/ */
export function ChangeRequestReview({ company }: { company: Company }) { export function ChangeRequestReview({ company }: { company: Company }) {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
const query = useQuery( const query = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }), api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
); );
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Stack> </Stack>
)} )}
<Group justify="flex-end" gap="sm"> {/* Reviewing the diff is `customers:view`; deciding on it is
<Button `customers:verify`. Without it the request stays readable but
variant="light" un-actionable. */}
color="red" {canReview && (
onClick={() => { <Group justify="flex-end" gap="sm">
setRejectId(pending.id); <Button
setNote(""); variant="light"
}} color="red"
> onClick={() => {
Reject setRejectId(pending.id);
</Button> setNote("");
<Button }}
color="edr-green" >
loading={approve.isPending} Reject
onClick={() => approve.mutate({ id: pending.id })} </Button>
> <Button
Approve changes color="edr-green"
</Button> loading={approve.isPending}
</Group> onClick={() => approve.mutate({ id: pending.id })}
>
Approve changes
</Button>
</Group>
)}
</Stack> </Stack>
</Card> </Card>
)} )}

View File

@@ -11,6 +11,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { import type {
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
* an already-active profile is still managable. * an already-active profile is still managable.
*/ */
/**
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
* `companies.controller.ts` — approving is a different authority from
* suspending, and both go through the same endpoint. Keep the two in step.
*/
const STATUS_PERM: Record<ProfileStatus, string> = {
active: FREIGHT_PERMS.customers.verify,
pending: FREIGHT_PERMS.customers.verify,
rejected: FREIGHT_PERMS.customers.verify,
suspended: FREIGHT_PERMS.customers.deactivate,
blacklisted: FREIGHT_PERMS.customers.deactivate,
};
export function ProfileApprovalActions({ export function ProfileApprovalActions({
profileId, profileId,
status, status,
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
status: ProfileStatus; status: ProfileStatus;
locked?: boolean; locked?: boolean;
}) { }) {
const { user } = useAuth();
/** The API rejects these anyway — hide rather than offer a button that 403s. */
const canSet = (next: ProfileStatus) =>
hasPermission(user, STATUS_PERM[next]);
const { mutate, isPending } = useMutation( const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(), api.customers.setProfileStatus.mutationOptions(),
); );
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
} }
if (status === "pending") { if (status === "pending") {
if (!canSet("active") && !canSet("rejected")) return null;
return ( return (
<> <>
{decisionModal} {decisionModal}
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button {canSet("active") && (
size="xs" <Button
variant="light" size="xs"
color="edr-green" variant="light"
radius="md" color="edr-green"
loading={isPending} radius="md"
onClick={() => act("active")} loading={isPending}
> onClick={() => act("active")}
Approve >
</Button> Approve
<Button </Button>
size="xs" )}
variant="light" {canSet("rejected") && (
color="red" <Button
radius="md" size="xs"
onClick={() => openDecision("reject")} variant="light"
> color="red"
Reject radius="md"
</Button> onClick={() => openDecision("reject")}
>
Reject
</Button>
)}
</Group> </Group>
</> </>
); );
} }
if (status === "rejected") { if (status === "rejected") {
if (!canSet("active")) return null;
return ( return (
<Button <Button
size="xs" size="xs"
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
} }
if (status === "active") { if (status === "active") {
if (!canSet("suspended")) return null;
return ( return (
<> <>
{decisionModal} {decisionModal}
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
} }
if (status === "suspended") { if (status === "suspended") {
if (!canSet("active") && !canSet("blacklisted")) return null;
return ( return (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{decisionModal} {decisionModal}
<Button {canSet("active") && (
size="xs" <Button
variant="light" size="xs"
color="edr-green" variant="light"
radius="md" color="edr-green"
loading={isPending} radius="md"
onClick={() => openDecision("reactivate")} loading={isPending}
> onClick={() => openDecision("reactivate")}
Reactivate >
</Button> Reactivate
<Button </Button>
size="xs" )}
variant="light" {canSet("blacklisted") && (
color="red" <Button
radius="md" size="xs"
loading={isPending} variant="light"
onClick={() => act("blacklisted")} color="red"
> radius="md"
Blacklist loading={isPending}
</Button> onClick={() => act("blacklisted")}
>
Blacklist
</Button>
)}
</Group> </Group>
); );
} }
if (status === "blacklisted") { if (status === "blacklisted") {
if (!canSet("pending")) return null;
return ( return (
<Button <Button
size="xs" size="xs"

View File

@@ -209,6 +209,13 @@ const RuleEngineFormDialog = ({
next.containerTypeId = ""; next.containerTypeId = "";
next.cargoTypeId = ""; next.cargoTypeId = "";
} }
// Cargo kind (customs / lashing) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";
next.cargoTypeId = "";
next.rateUnit = "";
}
return next; return next;
}); });
}; };
@@ -333,6 +340,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)} value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading} disabled={selectOptionsLoading}
// Native required blocks submit while a mandatory select is empty —
// without it the form posts and the API 400s (e.g. a container
// customs/lashing rate with no container type picked).
required={field.required}
data={options data={options
.filter((opt) => opt.value !== "") .filter((opt) => opt.value !== "")
.map((opt) => ({ .map((opt) => ({

View File

@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
key: "clearance", key: "clearance",
label: "Clearance", label: "Clearance",
statuses: [ statuses: [
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY", "CLEARANCE_READY",

View File

@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Active", label: "Active",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
}, },
AWAITING_CLEARANCE_PAYMENT: {
label: "Clearance Fee Due",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
AWAITING_CLEARANCE_DOCUMENTS: { AWAITING_CLEARANCE_DOCUMENTS: {
label: "Awaiting Documents", label: "Awaiting Documents",
color: "bg-amber-50 text-amber-700 border-amber-200", color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
SIGNED_CUSTOMER: "cyan", SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo", FULLY_EXECUTED: "indigo",
CONTRACT_ACTIVE: "edr-green", CONTRACT_ACTIVE: "edr-green",
AWAITING_CLEARANCE_PAYMENT: "orange",
AWAITING_CLEARANCE_DOCUMENTS: "yellow", AWAITING_CLEARANCE_DOCUMENTS: "yellow",
CLEARANCE_UNDER_REVIEW: "yellow", CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green", CLEARANCE_READY_FOR_BOOKING: "edr-green",
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
color: "text-[color:var(--freight-brand)]", color: "text-[color:var(--freight-brand)]",
stage: 3, stage: 3,
}, },
AWAITING_CLEARANCE_PAYMENT: {
title: "Clearance Fee Due",
description:
"Customer must pay the prepaid clearance service fee before uploading documents.",
color: "text-orange-600",
stage: 3,
},
AWAITING_CLEARANCE_DOCUMENTS: { AWAITING_CLEARANCE_DOCUMENTS: {
title: "Awaiting Documents", title: "Awaiting Documents",
description: "Customer is uploading pre-booking clearance documents.", description: "Customer is uploading pre-booking clearance documents.",

View File

@@ -2039,6 +2039,7 @@
"setting": "ቅንብሮች", "setting": "ቅንብሮች",
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...", "loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል", "errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
"errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል",
"retry": "ደግመው ይሞክሩ", "retry": "ደግመው ይሞክሩ",
"assignAdmin": "አስተዳዳሪ መመደብ", "assignAdmin": "አስተዳዳሪ መመደብ",
"addAdmin": "አስተዳዳሪ ያክሉ", "addAdmin": "አስተዳዳሪ ያክሉ",
@@ -2651,6 +2652,18 @@
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ", "selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
"permissionsSelected": "{{count}} ተመርጠዋል",
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
"positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል",
"positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል",
"positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል",
"positionTypeNotFound": "የቦታ ዓይነት አልተገኘም",
"permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።",
"failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም",
"failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም",
"exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም",
"perFailed": "ፍቃድ መፍጠር አልተቻለም", "perFailed": "ፍቃድ መፍጠር አልተቻለም",
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ", "perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል", "updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",

View File

@@ -2057,6 +2057,7 @@
"setting": "Setting", "setting": "Setting",
"loadingAdmins": "Loading admins...", "loadingAdmins": "Loading admins...",
"errorLoadingAdmins": "Error loading admin data", "errorLoadingAdmins": "Error loading admin data",
"errorLoadingUnits": "Error loading units",
"retry": "Retry", "retry": "Retry",
"assignAdmin": "Assign Admin", "assignAdmin": "Assign Admin",
"addAdmin": "Add Admin", "addAdmin": "Add Admin",
@@ -2760,6 +2761,18 @@
"selectApplicationToLoadPermissions": "Select an application to load its permissions", "selectApplicationToLoadPermissions": "Select an application to load its permissions",
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
"copyPermissionsFailed": "Failed to copy permissions", "copyPermissionsFailed": "Failed to copy permissions",
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
"permissionsSelected": "{{count}} selected",
"positionTypeCreated": "Position type created",
"positionTypeUpdated": "Position type updated",
"positionTypeDeleted": "Position type deleted",
"positionTypeMigrated": "Position type migration updated",
"positionTypeNotFound": "Position type not found",
"permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.",
"failedToLoadPermissions": "Failed to load permissions",
"failedToLoadPositionTypes": "Failed to load position types",
"exportFailed": "Failed to export position type keys",
"perFailed": "Failed To Create Permission", "perFailed": "Failed To Create Permission",
"perSuccess": "Permission type created and permissions assigned", "perSuccess": "Permission type created and permissions assigned",
"updatePerSuccess": "Permission updated successfully", "updatePerSuccess": "Permission updated successfully",

View File

@@ -1525,6 +1525,7 @@
"setting": "Paramètre", "setting": "Paramètre",
"loadingAdmins": "Chargement des administrateurs...", "loadingAdmins": "Chargement des administrateurs...",
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur", "errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
"errorLoadingUnits": "Erreur lors du chargement des unités",
"retry": "Réessayer", "retry": "Réessayer",
"assignAdmin": "Assigner un administrateur", "assignAdmin": "Assigner un administrateur",
"addAdmin": "Ajouter un administrateur", "addAdmin": "Ajouter un administrateur",
@@ -1886,6 +1887,18 @@
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations", "selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
"copyPermissionsFailed": "Échec de la copie des autorisations", "copyPermissionsFailed": "Échec de la copie des autorisations",
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
"permissionsSelected": "{{count}} sélectionné(s)",
"positionTypeCreated": "Type de poste créé",
"positionTypeUpdated": "Type de poste mis à jour",
"positionTypeDeleted": "Type de poste supprimé",
"positionTypeMigrated": "Migration du type de poste mise à jour",
"positionTypeNotFound": "Type de poste introuvable",
"permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.",
"failedToLoadPermissions": "Échec du chargement des autorisations",
"failedToLoadPositionTypes": "Échec du chargement des types de poste",
"exportFailed": "Échec de l'exportation des clés de type de poste",
"perFailed": "Échec de la création de lautorisation", "perFailed": "Échec de la création de lautorisation",
"perSuccess": "Type dautorisation créé et autorisations assignées", "perSuccess": "Type dautorisation créé et autorisations assignées",
"updatePerSuccess": "Autorisation mise à jour avec succès", "updatePerSuccess": "Autorisation mise à jour avec succès",

View File

@@ -56,6 +56,8 @@ import {
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
downloadBookingFile, downloadBookingFile,
fetchViewableFile, fetchViewableFile,
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: company, isLoading } = useQuery( const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({ api.customers.getById.queryOptions({
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
// API's rule exactly, so no button is offered that the server would reject. // API's rule exactly, so no button is offered that the server would reject.
const stillOnboarding = company ? isOnboardingDraft(company) : false; const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true; const canReview = company ? hasSubmittedOnboarding(company) : true;
// Workflow gate (above) AND authority: asking the customer to correct a
// document is a `customers:verify` action, so a view-only reviewer reads the
// documents but is not offered the request-change control.
const canRequestDocChange =
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
/** Document the reviewer is asking the customer to correct; null = closed. */ /** Document the reviewer is asking the customer to correct; null = closed. */
const [changeRequestDoc, setChangeRequestDoc] = const [changeRequestDoc, setChangeRequestDoc] =
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
> >
<Download size={16} /> <Download size={16} />
</ActionIcon> </ActionIcon>
{canReview && ( {canRequestDocChange && (
<ActionIcon <ActionIcon
component="button" component="button"
type="button" type="button"
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
), ),
}, },
], ],
[view, canReview], [view, canRequestDocChange],
); );
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo( const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(

View File

@@ -25,6 +25,8 @@ const FIELD_LABELS: Record<string, string> = {
tradeDirection: "Direction", tradeDirection: "Direction",
containerTypeId: "Container type", containerTypeId: "Container type",
cargoTypeId: "Cargo type", cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
}; };
const fmtDateTime = (iso: string) => const fmtDateTime = (iso: string) =>
@@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) =>
hour12: false, hour12: false,
}); });
const fmtValue = (field: string, value: unknown): string => { const fmtValue = (
field: string,
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—"; if (value === null || value === undefined || value === "") return "—";
if (field === "rateValue") { if (field === "rateValue") {
const num = Number(value); const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString(); return Number.isNaN(num) ? String(value) : num.toLocaleString();
} }
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " "); return String(value).replace(/_/g, " ");
}; };
@@ -77,6 +87,8 @@ interface RateApprovalsSectionProps {
canDecide: boolean; canDecide: boolean;
approve: Decide; approve: Decide;
reject: Decide; reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
} }
/** /**
@@ -89,6 +101,7 @@ const RateApprovalsSection = ({
canDecide, canDecide,
approve, approve,
reject, reject,
yardLabels,
}: RateApprovalsSectionProps) => { }: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
@@ -212,11 +225,11 @@ const RateApprovalsSection = ({
{FIELD_LABELS[field] ?? field} {FIELD_LABELS[field] ?? field}
</Text> </Text>
<Text size="sm" c="dimmed" td="line-through"> <Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field])} {fmtValue(field, r.previousValues[field], yardLabels)}
</Text> </Text>
<ArrowRight size={13} /> <ArrowRight size={13} />
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{fmtValue(field, r.payload[field])} {fmtValue(field, r.payload[field], yardLabels)}
</Text> </Text>
</Group> </Group>
))} ))}

View File

@@ -269,6 +269,10 @@ const RuleEngineResourcePage = () => {
); );
const { data: yardOptions, isLoading: yardOptionsLoading } = const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField); useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
);
const usesApprovalRoleField = Boolean( const usesApprovalRoleField = Boolean(
config?.formFields.some( config?.formFields.some(
(f) => f.name === "requiredRole" || f.name === "blocksRole", (f) => f.name === "requiredRole" || f.name === "blocksRole",
@@ -661,6 +665,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates} canDecide={canApproveRates}
approve={rateChangeWorkflow.approve} approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject} reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
/> />
) : null} ) : null}

View File

@@ -171,12 +171,11 @@ const RATE_TRIGGERS = [
value: "WITH_RETURN", value: "WITH_RETURN",
}, },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" }, { label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" }, { label: "Lashing (bulk, per cargo type)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" }, { label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
]; ];
/** /**
@@ -211,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept * bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts. * in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/ */
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { const allowedRateUnits = (
appliesTo: string,
trigger: string,
cargoKind = "",
): string[] => {
if (appliesTo === "OTHER") { if (appliesTo === "OTHER") {
switch (trigger) { switch (trigger) {
case "OVERWEIGHT": case "OVERWEIGHT":
@@ -221,16 +224,18 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
case "DEMURRAGE": case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"]; return ["PER_CONTAINER", "PER_TON"];
case "WITH_RETURN": case "WITH_RETURN":
// Container-only service — bills per returned container. // Container-only service — per returned container, per wagon, or flat.
return ["PER_CONTAINER", "FLAT"]; return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
case "CANCELLATION": case "CANCELLATION":
return ["FLAT", "PER_INVOICE"]; return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE": case "CUSTOMS_CLEARANCE":
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL). // Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return ["FLAT"]; return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
: ["PER_CONTAINER", "PER_WAGON"];
case "LASHING": case "LASHING":
// Flat cargo-securing fee, billed once per booking. // Bulk-only cargo securing — per ton or per wagon.
return ["FLAT"]; return ["PER_TON", "PER_WAGON"];
case "CONSOLIDATION": case "CONSOLIDATION":
case "SHIPPING_LINE": case "SHIPPING_LINE":
case "PIL_EXTRA_FEE": case "PIL_EXTRA_FEE":
@@ -258,7 +263,11 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
const appliesTo = String(values.appliesTo ?? ""); const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS"; const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return []; if (!appliesTo) return [];
return allowedRateUnits(appliesTo, trigger).map(unitOption); return allowedRateUnits(
appliesTo,
trigger,
String(values.cargoKind ?? ""),
).map(unitOption);
}; };
const CURRENCIES = [ const CURRENCIES = [
@@ -659,7 +668,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: { filters: {
appliesTo: "OTHER", appliesTo: "OTHER",
trigger: trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE", "HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
}, },
}, },
], ],
@@ -715,7 +724,64 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showIf: (v) => showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || ["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" && (String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))), ["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
String(v.trigger ?? ""),
)),
},
// ── Cargo kind — customs clearance is priced separately for containers
// (one rate per container type) and bulk ───────────────────────────────
{
name: "cargoKind",
label: "Cargo kind",
type: "select",
required: true,
options: INTERCITY_KINDS,
placeholder: "Is this fee for containers or bulk?",
description:
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
showIf: (v) =>
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
// Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none.
getInitialValue: (record) =>
record.containerTypeId ? "CONTAINER" : "BULK",
},
// ── Container type — a container fee names the type it covers ─────────
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "CONTAINER",
},
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
required: true,
placeholder: "Which bulk commodity this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
optional: true,
placeholder: "All lashing commodities (optional)",
description:
"Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.",
showIf: (v) =>
v.appliesTo === "OTHER" && v.trigger === "LASHING",
}, },
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{ {

View File

@@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [
"CONTRACT_ACTIVE", "CONTRACT_ACTIVE",
"CONTRACT_CLOSED", "CONTRACT_CLOSED",
// Post counter-sign document-clearance gate. // Post counter-sign document-clearance gate.
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY", "CLEARANCE_READY",

View File

@@ -1,94 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { t } from "i18next";
type ActionsColumnProps = {
row: PositionTypeDto;
};
const ActionsColumn: React.FC<ActionsColumnProps> = ({ row }) => {
const navigate = useNavigate();
const [openDialog, setOpenDialog] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { deletePositionType } = usePositionTypes({ id: "" });
const handleDeleteClick = (id: string) => {
setDeletingId(id);
setOpenDialog(true);
};
const handleDeleteConfirm = async () => {
if (!deletingId) return;
await deletePositionType.mutateAsync(deletingId);
setOpenDialog(false);
setDeletingId(null);
};
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
navigate(`/user-management/position-management/edit/${row.id}`)
}
>
{t("common.Edit")}
</Button>
<AlertDialog open={openDialog} onOpenChange={setOpenDialog}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => handleDeleteClick(row.id)}
>
{t("userRecord.Delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("contentManagement.delMsg")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.delMsg2")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setOpenDialog(false);
setDeletingId(null);
}}
>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={deletePositionType.isPending}
>
{deletePositionType.isPending
? t("organization.deleting")
: t("organization.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default ActionsColumn;

View File

@@ -1,455 +1,529 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { import {
Form, Form,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormMessage, FormMessage,
} from "@/shared/common/ui/form"; } from "@/shared/common/ui/form";
import { toast } from "sonner"; import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import {
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; invalidatePositionTypeQueries,
import { useNavigate } from "react-router-dom"; usePositionTypes,
import { t } from "i18next"; } from "@/user-management/hooks/usePositionTypes";
import { useAuth } from "@/shared/context/AuthContext"; import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { useUnit } from "@/user-management/hooks/useUnit"; import { useNavigate } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next";
import { import { useAuth } from "@/shared/context/AuthContext";
Select, import { useUnit } from "@/user-management/hooks/useUnit";
SelectContent, import { useEffect, useMemo, useRef, useState } from "react";
SelectItem, import {
SelectTrigger, Select,
SelectValue, SelectContent,
} from "@/shared/common/ui/select"; SelectItem,
import { SingleSelect } from "@/shared/common/ui/single-select"; SelectTrigger,
import { UnitDto } from "@/user-management/dto/unit/unitDto"; SelectValue,
import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; } from "@/shared/common/ui/select";
import { useLocalizedName } from "@/shared/common/localizedName"; import { SingleSelect } from "@/shared/common/ui/single-select";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import i18n from "@/i18n"; import { useLocalizedName } from "@/shared/common/localizedName";
import { PermissionSearch } from "./PermissionSearch"; import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { useApplications } from "@/user-management/hooks/useApplications"; import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { PermissionSearch } from "./PermissionSearch";
const formSchema = z.object({ import { useApplications } from "@/user-management/hooks/useApplications";
nameAm: z.string().min(2), import { useQuery, useQueryClient } from "@tanstack/react-query";
nameEn: z.string().min(2),
permissions: z.array(z.string()), export interface CreatePositionFormProps {
}); mode?: "create" | "edit";
positionTypeId?: string;
type FormValues = z.infer<typeof formSchema>; initialValues?: {
nameAm: string;
export interface CreatePositionFormProps { nameEn: string;
mode?: "create" | "edit"; unitId: string;
positionTypeId?: string; key?: string;
initialValues?: { };
nameAm: string; onSuccess?: () => void;
nameEn: string; onCancel?: () => void;
unitId: string; }
key?: string;
}; export const CreatePositionForm = ({
onSuccess?: () => void; mode = "create",
onCancel?: () => void; positionTypeId,
} initialValues,
onSuccess,
export const CreatePositionForm = ({ onCancel,
mode = "create", }: CreatePositionFormProps = {}) => {
positionTypeId, const navigate = useNavigate();
initialValues, const { t } = useTranslation();
onSuccess, const { handleError } = useErrorHandler(t);
onCancel, const {
}: CreatePositionFormProps = {}) => { createPositionType,
const navigate = useNavigate(); updatePositionType,
const { positionTypes,
createPositionType, isLoading: isLoadingPositionTypes,
updatePositionType, isError: isErrorPositionTypes,
positionTypes, } = usePositionTypes();
isLoading: isLoadingPositionTypes, const { user } = useAuth();
} = usePositionTypes(); const { getList, getById } = useUnit();
const { user } = useAuth(); const localizedName = useLocalizedName();
const { getList, getById } = useUnit(); const userOrganizationId =
const localizedName = useLocalizedName(); user?.employee && user.employee.length > 0
const userOrganizationId = ? user.employee[0].organizationId
user?.employee && user.employee.length > 0 : undefined;
? user.employee[0].organizationId const [selectedApplicationId, setSelectedApplicationId] =
: undefined; useState<string>("");
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>( const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
userOrganizationId ?? "", const [isCopying, setIsCopying] = useState(false);
); const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
const [selectedUnitId, setSelectedUnitId] = useState<string>( const hasLoadedEditData = useRef(false);
initialValues?.unitId ?? "", // Permissions the position type had when the form opened. Needed because the
); // API cannot represent "no permissions" (see onSubmit).
const [selectedApplicationId, setSelectedApplicationId] = const loadedPermissionCount = useRef(0);
useState<string>(""); const { applications, isLoading: isLoadingApplications } = useApplications();
const [copyFromPositionId, setCopyFromPositionId] = useState<string>(""); const queryClient = useQueryClient();
const [isCopying, setIsCopying] = useState(false);
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); const formSchema = useMemo(
const hasLoadedEditData = useRef(false); () =>
const lang = i18n.language; z.object({
const { applications, isLoading: isLoadingApplications } = useApplications(); nameEn: z.string().trim().min(2, t("organization.englishNameRequired")),
const queryClient = useQueryClient(); nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")),
organizationId: z.string().min(1, t("organization.organizationRequired")),
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( unitId: z.string().min(1, t("contentManagement.unitRequired")),
"Org", permissions: z.array(z.string()),
{ take: 3000 }, }),
); [t],
);
const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
selectedOrganizationId, type FormValues = z.infer<typeof formSchema>;
{ take: 3000, skip: 0 },
); const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
const organizationOptions = useMemo( defaultValues: {
() => nameAm: initialValues?.nameAm ?? "",
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ nameEn: initialValues?.nameEn ?? "",
value: org.id, organizationId: userOrganizationId ?? "",
label: localizedName(org.name) || org.id, unitId: initialValues?.unitId ?? "",
})), permissions: [],
[organizationsResponse, localizedName], },
); });
const unitOptions = useMemo( const selectedOrganizationId = form.watch("organizationId");
() =>
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
value: unit.id, "Org",
label: localizedName(unit.name) || unit.id, { take: 3000 },
})), );
[unitsResponse, localizedName],
); const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
selectedOrganizationId,
const { { take: 3000, skip: 0 },
data: editUnitResponse, );
isSuccess: isUnitSuccess,
isError: isUnitError, const organizationOptions = useMemo(
} = getById(initialValues?.unitId ?? ""); () =>
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({
const { value: org.id,
data: permissionsResponse, label: localizedName(org.name) || org.id,
isSuccess: isPermissionsSuccess, })),
isError: isPermissionsError, [organizationsResponse, localizedName],
} = useQuery({ );
queryKey: ["position-type-permissions", positionTypeId],
queryFn: () => const unitOptions = useMemo(
positionTypePermissionService.getPermissionsByPositionTypeId( () =>
positionTypeId!, (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({
), value: unit.id,
enabled: mode === "edit" && !!positionTypeId, label: localizedName(unit.name) || unit.id,
}); })),
// Reset the selected unit when the organization changes so a unit from a [unitsResponse, localizedName],
// different org can't be submitted by mistake. );
useEffect(() => {
if (mode === "edit") return; const {
setSelectedUnitId(""); data: editUnitResponse,
}, [selectedOrganizationId, mode]); isSuccess: isUnitSuccess,
isError: isUnitError,
const form = useForm<FormValues>({ } = getById(initialValues?.unitId ?? "");
resolver: zodResolver(formSchema),
defaultValues: { const {
nameAm: initialValues?.nameAm ?? "", data: permissionsResponse,
nameEn: initialValues?.nameEn ?? "", isSuccess: isPermissionsSuccess,
permissions: [], isError: isPermissionsError,
}, } = useQuery({
}); queryKey: ["position-type-permissions", positionTypeId],
queryFn: () =>
useEffect(() => { positionTypePermissionService.getPermissionsByPositionTypeId(
if (mode !== "edit" || !initialValues || !positionTypeId) return; positionTypeId!,
if (hasLoadedEditData.current) return; ),
enabled: mode === "edit" && !!positionTypeId,
const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; });
const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
// A position type belongs to a unit, and a unit to an organization — IAM has
if (isUnitDone && isPermissionsDone) { // no organizationId on the type itself and no organization-scoped route, so
hasLoadedEditData.current = true; // the picked org narrows the list through its units. isSystem types are the
// shared "commons" and stay available to every organization.
const unit = editUnitResponse?.data; const orgUnitIds = useMemo(
if (unit) { () =>
setSelectedOrganizationId(unit.organizationId); new Set(
setSelectedUnitId(unit.id); (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
} else if (initialValues.unitId) { ),
setSelectedUnitId(initialValues.unitId); [unitsResponse],
} );
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; const copyFromOptions = useMemo(() => {
form.reset({ if (!selectedOrganizationId) return [];
nameAm: initialValues.nameAm, return positionTypes.filter(
nameEn: initialValues.nameEn, (type: PositionTypeDto) =>
permissions: ids, type.id !== positionTypeId &&
}); (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
);
setIsLoadingEditData(false); }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
}
}, [ // Reset the selected unit when the organization changes so a unit from a
mode, // different org can't be submitted by mistake. The copy source is cleared
initialValues, // too — it is scoped to the old organization.
positionTypeId, useEffect(() => {
isUnitSuccess, if (mode === "edit") return;
isUnitError, form.setValue("unitId", "");
isPermissionsSuccess, setCopyFromPositionId("");
isPermissionsError, }, [selectedOrganizationId, mode, form]);
editUnitResponse,
permissionsResponse, useEffect(() => {
form, if (mode !== "edit" || !initialValues || !positionTypeId) return;
]); if (hasLoadedEditData.current) return;
const handlePermissionChange = (permissionId: string, checked: boolean) => { const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError;
const currentPermissions = form.getValues("permissions"); const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
if (checked) {
form.setValue("permissions", [...currentPermissions, permissionId]); if (isUnitDone && isPermissionsDone) {
} else { hasLoadedEditData.current = true;
form.setValue(
"permissions", const unit = editUnitResponse?.data;
currentPermissions.filter((id) => id !== permissionId), const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
); loadedPermissionCount.current = ids.length;
}
}; form.reset({
nameAm: initialValues.nameAm,
const handleCopyFrom = async (positionTypeId: string) => { nameEn: initialValues.nameEn,
setCopyFromPositionId(positionTypeId); organizationId: unit?.organizationId ?? userOrganizationId ?? "",
if (!positionTypeId) { unitId: unit?.id ?? initialValues.unitId ?? "",
form.setValue("permissions", []); permissions: ids,
return; });
}
setIsCopying(true); setIsLoadingEditData(false);
try { }
const response = }, [
await positionTypePermissionService.getPermissionsByPositionTypeId( mode,
positionTypeId, initialValues,
); positionTypeId,
const ids = response.data.items?.map((p) => p.id) ?? []; isUnitSuccess,
form.setValue("permissions", ids); isUnitError,
} catch { isPermissionsSuccess,
toast.error(t("contentManagement.copyPermissionsFailed")); isPermissionsError,
} finally { editUnitResponse,
setIsCopying(false); permissionsResponse,
} userOrganizationId,
}; form,
]);
const onSubmit = async (values: FormValues) => {
try { const handlePermissionChange = (permissionId: string, checked: boolean) => {
if (!selectedUnitId) { const currentPermissions = form.getValues("permissions");
toast.error(t("organization.selectUnit")); form.setValue(
return; "permissions",
} checked
? [...currentPermissions, permissionId]
const payload = { : currentPermissions.filter((id) => id !== permissionId),
name: { );
am: values.nameAm, };
en: values.nameEn,
}, const handleCopyFrom = async (sourcePositionTypeId: string) => {
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), setCopyFromPositionId(sourcePositionTypeId);
unitId: selectedUnitId, setIsCopying(true);
}; try {
const response =
let targetId = positionTypeId; await positionTypePermissionService.getPermissionsByPositionTypeId(
sourcePositionTypeId,
if (mode === "edit" && positionTypeId) { );
await updatePositionType.mutateAsync({ const ids = response.data.items?.map((p) => p.id) ?? [];
id: positionTypeId, form.setValue("permissions", ids);
data: payload, } catch (error) {
}); handleError(error);
} else { toast.error(t("contentManagement.copyPermissionsFailed"));
const response = await createPositionType.mutateAsync(payload); } finally {
targetId = response.data.id; setIsCopying(false);
} }
};
if (targetId && values.permissions.length > 0) {
await positionTypePermissionService.assignPermissionsToPositionType({ const onSubmit = async (values: FormValues) => {
firstId: targetId, const payload = {
secondIds: values.permissions, name: { am: values.nameAm, en: values.nameEn },
}); key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
} unitId: values.unitId,
};
queryClient.invalidateQueries({
queryKey: ["position-type"], // Save the position type first. If this fails nothing else runs, and the
}); // mutation's own onError surfaces the reason (403 for built-in types,
queryClient.invalidateQueries({ queryKey: ["position-types"] }); // conflict on the globally-unique key, ...).
queryClient.invalidateQueries({ let targetId = positionTypeId;
queryKey: ["position-type-permissions"], try {
}); if (mode === "edit" && positionTypeId) {
toast.success(t("contentManagement.permissionSuccess")); await updatePositionType.mutateAsync({
id: positionTypeId,
if (onSuccess) { data: payload,
onSuccess(); });
} else { } else {
navigate("/user-management/position-management"); const response = await createPositionType.mutateAsync(payload);
} targetId = response.data.id;
} catch { }
toast.error(t("contentManagement.permissionFailed")); } catch {
} return; // already reported by the mutation's onError
}; }
if (isLoadingEditData) { // assign-seconds-for-first replaces the whole set, but an empty secondIds
return ( // fails server-side — so "unassign everything" is not expressible. Keep the
<div className="py-8 text-center text-muted-foreground"> // save and tell the user their permissions were left alone.
{t("common.loading")} const mustClearAll =
</div> values.permissions.length === 0 && loadedPermissionCount.current > 0;
);
} if (targetId && values.permissions.length > 0) {
try {
return ( await positionTypePermissionService.assignPermissionsToPositionType({
<Form {...form}> firstId: targetId,
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> secondIds: values.permissions,
<FormField });
control={form.control} } catch (error) {
name="nameEn" handleError(error);
render={({ field }) => ( invalidatePositionTypeQueries(queryClient);
<FormItem> toast.error(t("contentManagement.permissionsAssignFailed"));
<FormLabel>{t("contentManagement.englishName")}</FormLabel> return;
<FormControl> }
<Input {...field} placeholder="e.g. HR Coordinator" /> }
</FormControl>
<FormMessage /> invalidatePositionTypeQueries(queryClient);
</FormItem> queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] });
)}
/> if (mustClearAll) {
toast.warning(t("contentManagement.cannotClearAllPermissions"));
<FormField } else {
control={form.control} toast.success(t("contentManagement.permissionSuccess"));
name="nameAm" }
render={({ field }) => (
<FormItem> if (onSuccess) {
<FormLabel>{t("contentManagement.amharicName")}</FormLabel> onSuccess();
<FormControl> } else {
<Input {...field} placeholder="e.g. ሰብል አያት" /> navigate("/user-management/position-management");
</FormControl> }
<FormMessage /> };
</FormItem>
)} if (isLoadingEditData) {
/> return (
<div className="py-8 text-center text-muted-foreground">
{/* ✅ Organization (searchable, all orgs) */} {t("common.loading")}
<div className="mb-4 w-full"> </div>
<label className="block text-sm font-medium text-gray-700 mb-1"> );
{t("organization.organization") || "Organization"} }
</label>
<SingleSelect const selectedPermissionCount = form.watch("permissions").length;
options={organizationOptions} // form.formState.isSubmitting stays true for the whole async handler, so it
value={selectedOrganizationId} // also covers the permission-assignment call that follows the save.
onValueChange={setSelectedOrganizationId} const isBusy = form.formState.isSubmitting || isCopying;
placeholder={
isLoadingOrgs const copyFromPlaceholder = !selectedOrganizationId
? t("common.loading") ? t("contentManagement.selectOrganizationToCopy")
: t("organization.selectOrganization") || : isCopying || isLoadingPositionTypes || isLoadingUnits
"Select an organization" ? t("common.loading")
} : isErrorPositionTypes
/> ? t("contentManagement.failedToLoadPositionTypes")
</div> : t("contentManagement.selectPositionToCopy");
{/* ✅ Unit Selector — searchable, scoped to picked org */} return (
<div className="mb-4 w-full"> <Form {...form}>
<label className="block text-sm font-medium text-gray-700 mb-1"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{t("organization.selectUnit")} <FormField
</label> control={form.control}
<SingleSelect name="nameEn"
options={unitOptions} render={({ field }) => (
value={selectedUnitId} <FormItem>
onValueChange={setSelectedUnitId} <FormLabel>{t("contentManagement.englishName")}</FormLabel>
placeholder={ <FormControl>
!selectedOrganizationId <Input {...field} placeholder="e.g. HR Coordinator" />
? t("organization.selectOrganizationFirst") || </FormControl>
"Select an organization first" <FormMessage />
: isLoadingUnits </FormItem>
? t("common.loading") )}
: t("organization.selectUnit") />
}
/> <FormField
</div> control={form.control}
name="nameAm"
<div className="mb-4 w-1/2"> render={({ field }) => (
<label className="block text-sm font-medium text-gray-700"> <FormItem>
{t("contentManagement.selectApplication")} <FormLabel>{t("contentManagement.amharicName")}</FormLabel>
</label> <FormControl>
<Select <Input {...field} placeholder="e.g. ሰብል አያት" />
value={selectedApplicationId} </FormControl>
onValueChange={(value) => setSelectedApplicationId(value)} <FormMessage />
disabled={isLoadingApplications}> </FormItem>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"> )}
<SelectValue placeholder="Select an Application" /> />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto"> {/* Organization (searchable, all orgs) */}
{applications?.map((app) => ( <FormField
<SelectItem key={app.id} value={app.id}> control={form.control}
{lang === "en" ? app.name.en : app.name.am} name="organizationId"
</SelectItem> render={({ field }) => (
))} <FormItem>
</SelectContent> <FormLabel>{t("organization.organization")}</FormLabel>
</Select> <SingleSelect
</div> options={organizationOptions}
value={field.value}
<div className="space-y-2"> onValueChange={field.onChange}
<label className="block text-sm font-medium text-gray-700"> placeholder={
{t("contentManagement.copyPermissionsFrom")} isLoadingOrgs
</label> ? t("common.loading")
<Select : t("organization.selectOrganization")
value={copyFromPositionId} }
onValueChange={handleCopyFrom} />
disabled={isLoadingPositionTypes || isCopying}> <FormMessage />
<SelectTrigger className="w-full"> </FormItem>
<SelectValue )}
placeholder={ />
isCopying
? t("common.loading") {/* Unit Selector — searchable, scoped to picked org */}
: t("contentManagement.selectPositionToCopy") <FormField
} control={form.control}
/> name="unitId"
</SelectTrigger> render={({ field }) => (
<SelectContent className="max-h-60 overflow-y-auto"> <FormItem>
{positionTypes.map((p: PositionTypeDto) => ( <FormLabel>{t("organization.selectUnit")}</FormLabel>
<SelectItem key={p.id} value={p.id}> <SingleSelect
{localizedName(p.name)} options={unitOptions}
</SelectItem> value={field.value}
))} onValueChange={field.onChange}
</SelectContent> placeholder={
</Select> !selectedOrganizationId
<p className="text-xs text-muted-foreground"> ? t("organization.selectOrganizationFirst")
{t("contentManagement.copyPermissionsHint")} : isLoadingUnits
</p> ? t("common.loading")
</div> : t("organization.selectUnit")
}
<FormField />
control={form.control} <FormMessage />
name="permissions" </FormItem>
render={({ field }) => ( )}
<FormItem> />
<FormLabel>{t("contentManagement.permission")}</FormLabel>
<PermissionSearch <div className="mb-4 w-1/2">
selectedPermissions={field.value} <label className="block text-sm font-medium text-gray-700">
onPermissionChange={handlePermissionChange} {t("contentManagement.selectApplication")}
applicationId={selectedApplicationId} </label>
/> <Select
<FormMessage /> value={selectedApplicationId}
</FormItem> onValueChange={setSelectedApplicationId}
)} disabled={isLoadingApplications}>
/> <SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue
<div className="flex justify-end gap-4"> placeholder={
<Button isLoadingApplications
type="button" ? t("common.loading")
variant="outline" : t("contentManagement.selectApplication")
onClick={() => { }
if (onCancel) { />
onCancel(); </SelectTrigger>
} else { <SelectContent className="max-h-60 overflow-y-auto">
window.history.back(); {applications?.map((app) => (
} <SelectItem key={app.id} value={app.id}>
}}> {localizedName(app.name)}
{t("common.Cancel")} </SelectItem>
</Button> ))}
<Button </SelectContent>
type="submit" </Select>
disabled={ </div>
createPositionType.isPending || updatePositionType.isPending
}> <div className="space-y-2">
{mode === "edit" ? t("delegation.update") : t("delegation.save")} <label className="block text-sm font-medium text-gray-700">
</Button> {t("contentManagement.copyPermissionsFrom")}
</div> </label>
</form> <Select
</Form> value={copyFromPositionId}
); onValueChange={handleCopyFrom}
}; disabled={
!selectedOrganizationId ||
isLoadingPositionTypes ||
isLoadingUnits ||
isCopying
}>
<SelectTrigger className="w-full">
<SelectValue placeholder={copyFromPlaceholder} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{copyFromOptions.map((p: PositionTypeDto) => (
<SelectItem key={p.id} value={p.id}>
{localizedName(p.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("contentManagement.copyPermissionsHint")}
</p>
</div>
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contentManagement.permission")}
{selectedPermissionCount > 0 && (
<span className="ml-2 font-normal text-muted-foreground">
(
{t("contentManagement.permissionsSelected", {
count: selectedPermissionCount,
})}
)
</span>
)}
</FormLabel>
<PermissionSearch
selectedPermissions={field.value}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
disabled={isBusy}
onClick={() => {
if (onCancel) {
onCancel();
} else {
window.history.back();
}
}}>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isBusy}>
{form.formState.isSubmitting
? t("common.saving")
: mode === "edit"
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,160 +1,193 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label"; import { Label } from "@/shared/common/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/common/ui/select"; } from "@/shared/common/ui/select";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useAuth } from "@/shared/context/AuthContext"; import { useUnit } from "@/user-management/hooks/useUnit";
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; import { useAuth } from "@/shared/context/AuthContext";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { useApplications } from "@/user-management/hooks/useApplications"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { PermissionSearch } from "./PermissionSearch"; import { useApplications } from "@/user-management/hooks/useApplications";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; import { PermissionSearch } from "./PermissionSearch";
import { useLocalizedName } from "@/shared/common/localizedName"; import { useLocalizedName } from "@/shared/common/localizedName";
import { UnitDto } from "@/user-management/dto/unit/unitDto"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { t } from "i18next"; import { t } from "i18next";
export const EditPositionForm = ({ id }: { id: string }) => { export const EditPositionForm = ({ id }: { id: string }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { getList } = useUnit(); const { getList } = useUnit();
const localizedName = useLocalizedName(); const localizedName = useLocalizedName();
const { positionType, isLoadingSingle } = usePositionTypes({ id }); const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({
id,
const organizationId = });
user?.employee && user.employee.length > 0
? user.employee[0].organizationId const organizationId =
: undefined; user?.employee && user.employee.length > 0
? user.employee[0].organizationId
const { applications, isLoading: isLoadingApplications } = useApplications(); : undefined;
const { data: unitsResponse } = getList(organizationId || "", { const { applications, isLoading: isLoadingApplications } = useApplications();
take: 300,
skip: 0, const { data: unitsResponse } = getList(organizationId || "", {
}); take: 300,
skip: 0,
const [selectedApplicationId, setSelectedApplicationId] = });
useState<string>("");
const [assignedPermissions, setAssignedPermissions] = useState< const [selectedApplicationId, setSelectedApplicationId] =
PermissionDto[] useState<string>("");
>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); // Shares the cache key CreatePositionForm writes under, so editing a position
// type's permissions refreshes this view too.
useEffect(() => { const {
const load = async () => { data: assignedResponse,
if (!positionType) return; isLoading: isLoadingPermissions,
setIsLoadingPermissions(true); isError: isErrorPermissions,
try { } = useQuery({
const assigned = queryKey: ["position-type-permissions", id],
await positionTypePermissionService.getPermissionsByPositionTypeId( queryFn: () =>
positionType.id, positionTypePermissionService.getPermissionsByPositionTypeId(id),
); enabled: !!id,
setAssignedPermissions(assigned.data.items ?? []); });
} finally {
setIsLoadingPermissions(false); const assignedPermissions = assignedResponse?.data?.items ?? [];
}
}; if (isLoadingSingle) {
load(); return (
}, [positionType]); <p className="py-8 text-center text-muted-foreground">
{t("common.loading")}
if (isLoadingSingle) return <p>Loading...</p>; </p>
if (!positionType) return null; );
}
const unit = unitsResponse?.data?.items?.find(
(u: UnitDto) => u.id === positionType.unitId, if (isErrorSingle || !positionType) {
); return (
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; <div className="space-y-4">
<p className="py-8 text-center text-red-500">
return ( {t("contentManagement.positionTypeNotFound")}
<div className="space-y-6"> </p>
<div className="space-y-2"> <div className="flex justify-end">
<Label>{t("contentManagement.englishName")}</Label> <Button
<Input value={positionType.name.en} disabled readOnly /> type="button"
</div> variant="outline"
onClick={() => navigate("/user-management/position-management")}>
<div className="space-y-2"> {t("common.Back")}
<Label>{t("contentManagement.amharicName")}</Label> </Button>
<Input value={positionType.name.am} disabled readOnly /> </div>
</div> </div>
);
<div className="space-y-2"> }
<Label>Key</Label>
<Input value={positionType.key} disabled readOnly /> const unit = unitsResponse?.data?.items?.find(
</div> (u: UnitDto) => u.id === positionType.unitId,
);
<div className="space-y-2"> const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
<Label>{t("contentManagement.selectApplication")}</Label>
<Select return (
value={selectedApplicationId} <div className="space-y-6">
onValueChange={(value) => setSelectedApplicationId(value)} <div className="space-y-2">
disabled={isLoadingApplications} <Label>{t("contentManagement.englishName")}</Label>
> <Input value={positionType.name.en} disabled readOnly />
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"> </div>
<SelectValue placeholder="Select an Application" />
</SelectTrigger> <div className="space-y-2">
<SelectContent className="max-h-60 overflow-y-auto"> <Label>{t("contentManagement.amharicName")}</Label>
{applications?.map((app: any) => ( <Input value={positionType.name.am} disabled readOnly />
<SelectItem key={app.id} value={app.id}> </div>
{localizedName(app.name)}
</SelectItem> <div className="space-y-2">
))} <Label>{t("contentManagement.key")}</Label>
</SelectContent> <Input value={positionType.key} disabled readOnly />
</Select> </div>
</div>
<div className="space-y-2">
<div className="space-y-2"> <Label>{t("organization.selectUnit")}</Label>
<Label>{t("contentManagement.permission")}</Label> <Input value={unitName ?? ""} disabled readOnly />
{selectedApplicationId ? ( </div>
<PermissionSearch
selectedPermissions={assignedPermissions.map((perm) => perm.id)} <div className="space-y-2">
onPermissionChange={() => { <Label>{t("contentManagement.selectApplication")}</Label>
// view-only mode in edit form <Select
}} value={selectedApplicationId}
applicationId={selectedApplicationId} onValueChange={setSelectedApplicationId}
disabled disabled={isLoadingApplications}>
/> <SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
) : ( <SelectValue
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto"> placeholder={
{isLoadingPermissions ? ( isLoadingApplications
<div className="text-center py-4 text-gray-500">Loading...</div> ? t("common.loading")
) : assignedPermissions.length === 0 ? ( : t("contentManagement.selectApplication")
<div className="text-center py-4 text-gray-500"> }
{t("contentManagement.noPermissionsAvailable")} />
</div> </SelectTrigger>
) : ( <SelectContent className="max-h-60 overflow-y-auto">
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2"> {applications?.map((app) => (
{assignedPermissions.map((perm) => ( <SelectItem key={app.id} value={app.id}>
<li {localizedName(app.name)}
key={perm.id} </SelectItem>
className="capitalize text-sm py-1 px-2 rounded bg-muted/40" ))}
> </SelectContent>
{localizedName(perm.name)} </Select>
</li> </div>
))}
</ul> <div className="space-y-2">
)} <Label>{t("contentManagement.permission")}</Label>
</div> {selectedApplicationId ? (
)} <PermissionSearch
</div> selectedPermissions={assignedPermissions.map((perm) => perm.id)}
onPermissionChange={() => {
<div className="flex justify-end"> // view-only mode in edit form
<Button }}
type="button" applicationId={selectedApplicationId}
variant="outline" disabled
onClick={() => navigate("/user-management/position-management")} />
> ) : (
{t("common.Back")} <div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
</Button> {isLoadingPermissions ? (
</div> <div className="text-center py-4 text-gray-500">
</div> {t("common.loading")}
); </div>
}; ) : isErrorPermissions ? (
<div className="text-center py-4 text-red-500">
{t("contentManagement.failedToLoadPermissions")}
</div>
) : assignedPermissions.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{assignedPermissions.map((perm) => (
<li
key={perm.id}
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
{localizedName(perm.name)}
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/user-management/position-management")}>
{t("common.Back")}
</Button>
</div>
</div>
);
};

View File

@@ -1,152 +1,120 @@
import React, { useState, useEffect, useMemo, useRef } from "react"; import React, { useState, useEffect } from "react";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Checkbox } from "@/shared/common/ui/checkbox"; import { Checkbox } from "@/shared/common/ui/checkbox";
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
import { useLocalizedName } from "@/shared/common/localizedName"; import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next"; import { t } from "i18next";
import { Search, Loader2 } from "lucide-react"; import { Search, Loader2 } from "lucide-react";
interface PermissionSearchProps { interface PermissionSearchProps {
selectedPermissions: string[]; selectedPermissions: string[];
onPermissionChange: (permissionId: string, checked: boolean) => void; onPermissionChange: (permissionId: string, checked: boolean) => void;
applicationId?: string; applicationId?: string;
disabled?: boolean; disabled?: boolean;
} }
const INITIAL_TAKE = 50; // Initial number of items to fetch // One request per application. This used to fetch 50, read `count` off the
// response and immediately refetch with take = count — two round trips on every
export const PermissionSearch: React.FC<PermissionSearchProps> = ({ // mount for the same list.
selectedPermissions, const TAKE = 1000;
onPermissionChange,
applicationId, export const PermissionSearch: React.FC<PermissionSearchProps> = ({
disabled = false, selectedPermissions,
}) => { onPermissionChange,
const [searchTerm, setSearchTerm] = useState(""); applicationId,
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); disabled = false,
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 }) => {
const hasSetTotalCount = useRef(false); // Track if we've set the total count const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const localizedName = useLocalizedName();
const localizedName = useLocalizedName();
/** ------------------ 1. Debounce Search ------------------ */
/** ------------------ 1. Debounce Search ------------------ */ useEffect(() => {
useEffect(() => { const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
const timer = setTimeout(() => { return () => clearTimeout(timer);
setDebouncedSearchTerm(searchTerm); }, [searchTerm]);
setTake(INITIAL_TAKE); // Reset to 50
hasSetTotalCount.current = false; // Reset the flag /** ------------------ 2. Fetch Permissions ------------------ */
}, 300); // The API does the filtering. Filtering the result again on the *undebounced*
// term used to blank the list for 300ms on every keystroke.
return () => clearTimeout(timer); const { permissions, isPermissionsLoading } = usePermissionManager({
}, [searchTerm]); params: applicationId
? {
/** ------------------ 2. Fetch Permissions ------------------ */ take: TAKE,
const { permissions, isPermissionsLoading } = usePermissionManager({ skip: 0,
params: applicationId search: debouncedSearchTerm || undefined,
? { applicationId,
take, }
skip: 0, // Always skip 0, we fetch everything at once : undefined,
search: debouncedSearchTerm || undefined, });
applicationId,
} const items = permissions?.items ?? [];
: undefined,
}); /** ------------------ Render ------------------ */
return (
/** ------------------ 3. Update take to total count after first fetch ------------------ */ <div className="space-y-4">
useEffect(() => { {/* Search Input */}
if ( <div className="relative">
permissions?.count && <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
!hasSetTotalCount.current && <Input
take !== permissions.count placeholder={t("contentManagement.searchPermissions")}
) { value={searchTerm}
hasSetTotalCount.current = true; onChange={(e) => setSearchTerm(e.target.value)}
setTake(permissions.count); // Fetch all items className="pl-10"
} />
}, [permissions?.count, take]); </div>
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */ {/* Permission List Container */}
const filteredPermissions = useMemo(() => { {!applicationId ? (
if (!permissions?.items?.length) return []; <div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
if (!searchTerm.trim()) return permissions.items; {t("contentManagement.selectApplicationToLoadPermissions")}
</div>
return permissions.items.filter((perm: PermissionDto) => { ) : isPermissionsLoading ? (
const name = localizedName(perm.name).toLowerCase(); <div className="flex justify-center py-10">
const key = perm.key.toLowerCase(); <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
const search = searchTerm.toLowerCase(); </div>
return name.includes(search) || key.includes(search); ) : (
}); <div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
}, [permissions?.items, searchTerm, localizedName]); {items.length === 0 ? (
<div className="text-center py-4 text-gray-500">
/** ------------------ Render ------------------ */ {debouncedSearchTerm
return ( ? t("contentManagement.noPermissionsFound")
<div className="space-y-4"> : t("contentManagement.noPermissionsAvailable")}
{/* Search Input */} </div>
<div className="relative"> ) : (
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input {items.map((perm: PermissionDto) => (
placeholder={t("contentManagement.searchPermissions")} <div
value={searchTerm} key={perm.id}
onChange={(e) => setSearchTerm(e.target.value)} className="flex flex-row items-start space-x-3 space-y-0">
className="pl-10" <Checkbox
/> checked={selectedPermissions.includes(perm.id)}
</div> disabled={disabled}
onCheckedChange={(checked) => {
{/* Permission List Container */} if (!disabled) onPermissionChange(perm.id, !!checked);
{!applicationId ? ( }}
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500"> />
{t("contentManagement.selectApplicationToLoadPermissions") || <label className="capitalize cursor-pointer font-normal text-sm">
"Select an application to load permissions."} {localizedName(perm.name)}
</div> </label>
) : isPermissionsLoading ? ( </div>
<div className="flex justify-center py-10"> ))}
<Loader2 className="h-8 w-8 animate-spin text-gray-400" /> </div>
</div> )}
) : ( </div>
<div )}
ref={scrollContainerRef}
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background" {/* Footer Info */}
> {items.length > 0 && (
{filteredPermissions.length === 0 ? ( <div className="text-xs text-muted-foreground px-1">
<div className="text-center py-4 text-gray-500"> {t("contentManagement.showingPermissions", {
{searchTerm count: items.length,
? t("contentManagement.noPermissionsFound") total: permissions?.count || 0,
: t("contentManagement.noPermissionsAvailable")} })}
</div> </div>
) : ( )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> </div>
{filteredPermissions.map((perm: PermissionDto) => ( );
<div };
key={perm.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<Checkbox
checked={selectedPermissions.includes(perm.id)}
disabled={disabled}
onCheckedChange={(checked) => {
if (!disabled) onPermissionChange(perm.id, !!checked);
}}
/>
<label className="capitalize cursor-pointer font-normal text-sm">
{localizedName(perm.name)}
</label>
</div>
))}
</div>
)}
</div>
)}
{/* Footer Info */}
{filteredPermissions.length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("contentManagement.showingPermissions", {
count: filteredPermissions.length,
total: permissions?.count || 0,
})}
</div>
)}
</div>
);
};

View File

@@ -1,276 +1,253 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo, useCallback } from "react";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/shared/common/ui/card"; } from "@/shared/common/ui/card";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
import { positionTypeService } from "@/user-management/services/api/positionTypesService"; import { positionTypeService } from "@/user-management/services/api/positionTypesService";
import { t } from "i18next"; import { t } from "i18next";
import { useUnit } from "@/user-management/hooks/useUnit"; import { toast } from "sonner";
import { useAuth } from "@/shared/context/AuthContext"; import { useUnit } from "@/user-management/hooks/useUnit";
import { import { useAuth } from "@/shared/context/AuthContext";
Select, import {
SelectContent, Select,
SelectItem, SelectContent,
SelectTrigger, SelectItem,
SelectValue, SelectTrigger,
} from "@/shared/common/ui/select"; SelectValue,
import { UnitDto } from "@/user-management/dto/unit/unitDto"; } from "@/shared/common/ui/select";
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
export default function PositionManagement() {
const [pageIndex, setPageIndex] = useState(0); export default function PositionManagement() {
const pageSize = 10; const [pageIndex, setPageIndex] = useState(0);
const [isExporting, setIsExporting] = useState<boolean>(false); const pageSize = 10;
const [searchTerm, setSearchTerm] = useState(""); const [isExporting, setIsExporting] = useState<boolean>(false);
const { createConfiguration } = usePositionTypeConfiguration(); const [searchTerm, setSearchTerm] = useState("");
const { user } = useAuth(); const { user } = useAuth();
const { getAccessibleList } = useUnit(); const { getAccessibleList } = useUnit();
const organizationId = user?.employee?.[0]?.organizationId; const organizationId = user?.employee?.[0]?.organizationId;
const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { const { data: unitsResponse, isError: isUnitsError } = getAccessibleList(
take: 300, organizationId ?? "",
skip: 0, {
}); take: 300,
skip: 0,
// Add state for selected unitId },
// Default: if super_admin => "All", otherwise wait for units );
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
// Add state for selected unitId
useEffect(() => { // Default: if super_admin => "All", otherwise wait for units
// If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All" const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
if (!selectedUnitId) {
if (unitsResponse?.data?.items?.length) { useEffect(() => {
setSelectedUnitId(unitsResponse.data.items[0].id); // If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
} else { if (!selectedUnitId) {
setSelectedUnitId("All"); if (unitsResponse?.data?.items?.length) {
} setSelectedUnitId(unitsResponse.data.items[0].id);
} } else {
}, [unitsResponse, selectedUnitId]); setSelectedUnitId("All");
}
// Reset to first page whenever the search term or unit changes so users }
// land on the first page of matches instead of an empty later page. }, [unitsResponse, selectedUnitId]);
useEffect(() => {
setPageIndex(0); // Reset to first page whenever the search term or unit changes so users
}, [searchTerm, selectedUnitId]); // land on the first page of matches instead of an empty later page.
useEffect(() => {
const handlePageChange = (newPage: number) => { setPageIndex(0);
setPageIndex(newPage); }, [searchTerm, selectedUnitId]);
};
const { const handlePageChange = (newPage: number) => {
positionTypeResponse, setPageIndex(newPage);
isLoading, };
positionTypeByUnitId,
refetch, const showingAllUnits = selectedUnitId === "All";
refetchPosition,
} = usePositionTypes({ const {
params: { positionTypeResponse,
take: 1000, isLoading,
skip: 0, isError,
orderBy: "updatedAt:DESC", positionTypeByUnitId,
}, isLoadingPosition,
unitId: selectedUnitId === "All" ? undefined : selectedUnitId, isErrorPosition,
}); refetch,
refetchPosition,
// Fetch position types without unitId for migration options } = usePositionTypes({
const { params: {
positionTypeResponse: globalPositionTypes, take: 1000,
refetch: refetchGlobalPositionTypes, skip: 0,
} = usePositionTypes({ orderBy: "updatedAt:DESC",
params: { },
take: 1000, // Get all global position types unitId: showingAllUnits ? undefined : selectedUnitId,
skip: 0, });
orderBy: "updatedAt:DESC",
}, // Refresh whichever list is on screen. `positionTypeResponse` is the
unitId: undefined, // Explicitly fetch position types without unitId // unscoped fetch, so it doubles as the migration-target source — no second
}); // usePositionTypes() call needed (its cache key ignores unitId, so a second
// call returned the very same query).
// Create a combined refetch function for the onDelete callback const handlePositionTypeChanged = useCallback(async () => {
const handlePositionTypeDeleted = async () => { await (showingAllUnits ? refetch() : refetchPosition());
await Promise.all([ }, [showingAllUnits, refetch, refetchPosition]);
selectedUnitId === "All" ? refetch() : refetchPosition(),
refetchGlobalPositionTypes(), const columns = useMemo(
]); () =>
}; createPositionTypeColumns(
const handleToggle = async ( positionTypeResponse,
positionTypeId: string, handlePositionTypeChanged,
checked: boolean, handlePositionTypeChanged,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", ),
) => { [positionTypeResponse, handlePositionTypeChanged],
if (!selectedUnitId || selectedUnitId === "All") return; );
await createConfiguration({ const allItems = useMemo(
positionTypeId, () =>
timeframe: "yearly", (showingAllUnits
organizationId: organizationId!, ? positionTypeResponse?.items
canReceiveRecord: field === "canReceiveRecord" ? checked : false, : positionTypeByUnitId?.items) || [],
canAssignRecord: field === "canAssignRecord" ? checked : false, [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items],
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, );
});
const filteredItems = useMemo(() => {
await handlePositionTypeDeleted(); const trimmed = searchTerm.trim().toLowerCase();
}; if (!trimmed) return allItems;
return allItems.filter((item: PositionTypeDto) => {
// Create columns with positionTypeResponse const en = (item?.name?.en || "").toLowerCase();
const columns = useMemo( const am = (item?.name?.am || "").toLowerCase();
() => const key = (item?.key || "").toLowerCase();
createPositionTypeColumns( return (
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed)
globalPositionTypes, );
handlePositionTypeDeleted, });
handlePositionTypeDeleted, }, [allItems, searchTerm]);
handleToggle, // ← pass toggle handler
selectedUnitId === "All", // ← isGlobal: hide toggle when "All" const paginatedItems = useMemo(() => {
), const start = pageIndex * pageSize;
[ return filteredItems.slice(start, start + pageSize);
selectedUnitId, }, [filteredItems, pageIndex, pageSize]);
positionTypeResponse,
positionTypeByUnitId, // Track whichever query is actually feeding the table — picking a unit used
globalPositionTypes, // to leave the previous unit's rows on screen with no loading state.
], const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition;
); const isErrorList = showingAllUnits ? isError : isErrorPosition;
const allItems = useMemo(
() => const exportTypes = () => {
(selectedUnitId === "All" setIsExporting(true);
? positionTypeResponse?.items positionTypeService
: positionTypeByUnitId?.items) || [], .getAll({ take: 3000 })
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], .then((allPositionKeys) => {
); const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
const filteredItems = useMemo(() => { if (!positionTypeKeys?.length) {
const trimmed = searchTerm.trim().toLowerCase(); toast.error(t("contentManagement.exportFailed"));
if (!trimmed) return allItems; return;
return allItems.filter((item: any) => { }
const en = (item?.name?.en || "").toLowerCase();
const am = (item?.name?.am || "").toLowerCase(); // One key per line, downloaded as a plain text file.
const key = (item?.key || "").toLowerCase(); const blob = new Blob([positionTypeKeys.join("\n")], {
return ( type: "text/plain",
en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) });
); const link = document.createElement("a");
}); link.href = URL.createObjectURL(blob);
}, [allItems, searchTerm]); link.download = "position_keys.txt";
link.click();
const paginatedItems = useMemo(() => { URL.revokeObjectURL(link.href);
const start = pageIndex * pageSize; })
return filteredItems.slice(start, start + pageSize); .catch(() => {
}, [filteredItems, pageIndex, pageSize]); toast.error(t("contentManagement.exportFailed"));
})
if (isLoading) { .finally(() => {
return <div>{t("contentManagement.addUser")}</div>; setIsExporting(false);
} });
};
const exportTypes = () => {
setIsExporting(true); return (
positionTypeService <div className="p-6 space-y-6">
.getAll({ <Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
take: 3000, <CardHeader className="flex flex-row justify-between items-center px-0">
}) <CardTitle className="text-xl font-semibold ">
.then((allPositionKeys) => { {t("contentManagement.permissionType")}
// Get the position type keys </CardTitle>
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); <Button onClick={exportTypes} disabled={isExporting}>
{isExporting
if (positionTypeKeys && positionTypeKeys.length > 0) { ? t("contentManagement.exporting")
// Convert the array of keys into a string, with each key on a new line : t("contentManagement.exportTypes")}
const fileContent = positionTypeKeys.join("\n"); </Button>
</CardHeader>
// Create a Blob from the string content {!!unitsResponse?.data?.items?.length && (
const blob = new Blob([fileContent], { type: "text/plain" }); <div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
// Create a link element to trigger the download {t("organization.selectUnit")}
const link = document.createElement("a"); </label>
<Select
// Create an object URL for the Blob value={selectedUnitId}
link.href = URL.createObjectURL(blob); onValueChange={(value) => setSelectedUnitId(value)}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
// Set the download attribute with a file name <SelectValue placeholder={t("organization.selectUnit")} />
link.download = "position_keys.txt"; </SelectTrigger>
<SelectContent>
// Programmatically trigger a click on the link to start the download <SelectItem key="all" value="All">
link.click(); {t("common.all")}
</SelectItem>
// Clean up by revoking the object URL {unitsResponse.data.items.map((unit: UnitDto) => (
URL.revokeObjectURL(link.href); <SelectItem key={unit.id} value={unit.id}>
} else { <span className="block truncate max-w-70">
console.error("No position type keys found."); {unit.name.en || unit.name.am}
} </span>
setIsExporting(false); </SelectItem>
}); ))}
}; </SelectContent>
</Select>
return ( </div>
<div className="p-6 space-y-6"> )}
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0"> {isUnitsError && (
<CardHeader className="flex flex-row justify-between items-center px-0"> <p className="mb-4 text-sm text-red-500">
<CardTitle className="text-xl font-semibold "> {t("organization.errorLoadingUnits")}
{t("contentManagement.permissionType")} </p>
</CardTitle> )}
<Button onClick={exportTypes}> <CardContent className="px-0">
{isExporting {isLoadingList ? (
? t("contentManagement.exporting") <div className="py-10 text-center text-muted-foreground">
: t("contentManagement.exportTypes")} {t("common.loading")}
</Button> </div>
</CardHeader> ) : isErrorList ? (
{unitsResponse?.data?.items?.length > 0 && ( <div className="py-10 text-center text-red-500">
<div className="mb-4 w-1/2"> {t("contentManagement.failedToLoadPositionTypes")}
<label className="block text-sm font-medium text-gray-700"> </div>
Select Unit ) : (
</label> <AdvancedTable
<Select columns={columns}
value={selectedUnitId} data={paginatedItems}
onValueChange={(value) => setSelectedUnitId(value)} tableName="Positions"
> toolBarPosition="right"
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate"> itemCount={filteredItems.length}
<SelectValue placeholder="Select a Unit" /> onGlobalFilterChange={setSearchTerm}
</SelectTrigger> extraToolbar={
<SelectContent> <Link to="/user-management/position-management/new">
<SelectItem key="all" value="All"> <Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
All <Plus className="w-4 h-4 mr-2" />
</SelectItem> {t("contentManagement.newPermission")}
{unitsResponse?.data.items.map((unit: UnitDto) => ( </Button>
<SelectItem key={unit.id} value={unit.id}> </Link>
<span className="block truncate max-w-70"> }
{unit.name.en || unit.name.am} pageIndex={pageIndex}
</span> onPageChange={handlePageChange}
</SelectItem> nextFunction={() => handlePageChange(pageIndex + 1)}
))} prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
</SelectContent> />
</Select> )}
</div> </CardContent>
)} </Card>
<CardContent className="px-0"> </div>
<AdvancedTable );
columns={columns} }
data={paginatedItems}
tableName="Positions"
toolBarPosition="right"
itemCount={filteredItems.length}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Link to="/user-management/position-management/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("contentManagement.newPermission")}
</Button>
</Link>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,393 +1,251 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
} from "@/shared/common/ui/dropdown-menu"; } from "@/shared/common/ui/dropdown-menu";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog"; } from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
import { t } from "i18next"; import { t } from "i18next";
import PositionTypeMigrationModal from "./PostionTypeMigration"; import PositionTypeMigrationModal from "./PostionTypeMigration";
import { CreatePositionForm } from "./CreatePositionForm"; import { CreatePositionForm } from "./CreatePositionForm";
import { toast } from "sonner"; import { useLocalizedName } from "@/shared/common/localizedName";
import { useLocalizedName } from "@/shared/common/localizedName"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { positionTypeService } from "@/user-management/services/api/positionTypesService"; import {
import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; Dialog,
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; DialogContent,
import { Switch } from "@/shared/common/ui/switch"; DialogHeader,
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; DialogTitle,
import { useQueryClient } from "@tanstack/react-query"; } from "@/shared/common/ui/dialog";
import {
Dialog, interface PositionTypeResponse {
DialogContent, items: PositionTypeDto[];
DialogHeader, count: number;
DialogTitle, }
} from "@/shared/common/ui/dialog";
type ActionsCellProps = {
interface PositionTypeResponse { row: PositionTypeDto;
items: PositionTypeDto[]; globalPositionTypes?: PositionTypeResponse;
count: number; onDelete?: () => void | Promise<void>;
} onEdit?: () => void | Promise<void>;
};
type ActionsCellProps = {
row: PositionTypeDto | PositionTypeConfigurationDto; /*
globalPositionTypes?: PositionTypeResponse; * TODO(record-toggles): this menu used to carry CanReceiveRecord /
onDelete?: () => void | Promise<void>; * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's
onEdit?: () => void | Promise<void>; * PositionTypeConfiguration entity only has { id, organizationId,
onToggle?: ( * positionTypeId, timeframe } — verified against every local build (0.7.4
positionTypeId: string, * through 0.7.12) and the live swagger. canAssignRecord and
checked: boolean, * canCreateBankRecord do not exist anywhere in the IAM package, and the global
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The
) => void | Promise<void>; * reads were broken too: the list route filters on organizationId (the repo is
isGlobal?: boolean; // true when viewing "All" units — hide toggle * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a
}; * positionTypeId, so it always came back empty.
*
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({ * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by
row, * positionId — a per-position setting served by /api/position-configurations,
globalPositionTypes, * not a per-position-type one. Restoring this needs either that endpoint and a
onDelete, * position-level UI, or new columns on PositionTypeConfiguration in IAM.
onEdit, */
onToggle, const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
isGlobal = false, row,
}) => { globalPositionTypes,
const navigate = useNavigate(); onDelete,
const [dropdownOpen, setDropdownOpen] = useState(false); onEdit,
const [showMigrateDialog, setShowMigrateDialog] = useState(false); }) => {
const [showEditDialog, setShowEditDialog] = useState(false); const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [showMigrateDialog, setShowMigrateDialog] = useState(false);
const localizedName = useLocalizedName(); const [showEditDialog, setShowEditDialog] = useState(false);
const { handleError } = useErrorHandler(t); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const queryClient = useQueryClient(); const localizedName = useLocalizedName();
// Use row.id as the positionTypeId for the configuration lookup const { deletePositionType } = usePositionTypes();
const { // Create position type options from globalPositionTypes - only those WITHOUT unitId
configurations, const positionTypeOptions =
isLoadingConfigurations, globalPositionTypes?.items
updateConfiguration, .filter((item) => !item.unitId)
isUpdatingConfiguration, .map((item) => ({
} = usePositionTypeConfiguration( label: localizedName(item.name),
row?.id ?? null, // 👈 pass row.id as unitId value: item.id,
); })) || [];
const configItem = configurations[0]; // Only show migrate/delete actions if current row has a unitId
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; const canBeModified = !!row.unitId;
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; const handleView = () => {
navigate(`/user-management/position-management/edit/${row.id}`);
const invalidateConfig = () => { };
queryClient.invalidateQueries({
queryKey: ["positionTypeConfigurations", row.id], const handleMigrate = (e: Event) => {
}); e.preventDefault();
queryClient.invalidateQueries({ setDropdownOpen(false);
queryKey: ["positionTypeConfiguration", row.id], setShowMigrateDialog(true);
}); };
};
const handleEdit = (e: Event) => {
// Create position type options from globalPositionTypes - only those WITHOUT unitId e.preventDefault();
const positionTypeOptions = setDropdownOpen(false);
globalPositionTypes?.items setShowEditDialog(true);
.filter((item) => !item.unitId) };
.map((item) => ({
label: localizedName(item.name), // Goes through the mutation rather than the service directly, so the cache is
value: item.id, // invalidated and IAM's 403 for built-in types reaches the user.
})) || []; const handleDelete = async () => {
try {
// Only show migrate/delete actions if current row has a unitId await deletePositionType.mutateAsync(row.id);
const canBeModified = !!row.unitId; setShowDeleteDialog(false);
await onDelete?.();
const handleView = () => { } catch {
navigate(`/user-management/position-management/edit/${row.id}`); // reported by the mutation's onError
}; }
};
const handleMigrate = (e: Event) => {
e.preventDefault(); return (
setDropdownOpen(false); <>
setShowMigrateDialog(true); <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
}; <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
const handleEdit = (e: Event) => { <MoreVertical className="h-4 w-4" />
e.preventDefault(); <span className="sr-only">Open actions menu</span>
setDropdownOpen(false); </Button>
setShowEditDialog(true); </DropdownMenuTrigger>
};
<DropdownMenuContent
const handleDelete = async () => { align="end"
try { onInteractOutside={(e) => {
setIsDeleting(true); const target = e.target as HTMLElement;
await positionTypeService.delete(row.id); if (!target.closest('[role="dialog"]')) {
toast.success(t("common.DeletedSuccessfully")); setDropdownOpen(false);
setShowDeleteDialog(false); }
if (onDelete) { }}>
await onDelete(); <DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
}
} catch (error) { {canBeModified && (
handleError(error); <DropdownMenuItem
toast.error(t("common.FailedToDelete")); onSelect={handleMigrate}
} finally { className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
setIsDeleting(false); <Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
} <span> {t("common.Migrate")}</span>
}; </DropdownMenuItem>
)}
const handleToggleChange = async (checked: boolean) => {
if (isGlobal) return; {canBeModified && (
try { <DropdownMenuItem
if (configItem?.id) { onSelect={handleEdit}
await updateConfiguration({ className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
id: configItem.id, <Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
payload: { <span>{t("common.Edit")}</span>
organizationId: configItem.organizationId, </DropdownMenuItem>
positionTypeId: configItem.positionTypeId, )}
timeframe: configItem.timeframe,
canReceiveRecord: checked, <DropdownMenuItem
}, onSelect={handleView}
}); className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
} else { <Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
await onToggle?.(row.id, checked, "canReceiveRecord"); <span>{t("common.View")}</span>
} </DropdownMenuItem>
toast.success(t("incomingRecord.UpdatedSuccessfully"));
invalidateConfig(); {canBeModified && (
} catch (error) { <DropdownMenuItem
handleError(error); onSelect={() => {
toast.error(t("incomingRecord.FailedToUpdate")); setDropdownOpen(false);
} setShowDeleteDialog(true);
}; }}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
const handleAssignToggleChange = async (checked: boolean) => { <Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
if (isGlobal) return; <span>{t("common.Delete")}</span>
try { </DropdownMenuItem>
if (configItem?.id) { )}
await updateConfiguration({ </DropdownMenuContent>
id: configItem.id, </DropdownMenu>
payload: {
organizationId: configItem.organizationId, {showMigrateDialog && (
positionTypeId: configItem.positionTypeId, <PositionTypeMigrationModal
timeframe: configItem.timeframe, isOpen={showMigrateDialog}
canAssignRecord: checked, onClose={() => {
}, setShowMigrateDialog(false);
}); }}
} else { toId={row.id}
await onToggle?.(row.id, checked, "canAssignRecord"); toName={localizedName(row.name)}
} positionTypeOptions={positionTypeOptions}
toast.success(t("incomingRecord.UpdatedSuccessfully")); />
invalidateConfig(); )}
} catch (error) {
handleError(error); <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
toast.error(t("incomingRecord.FailedToUpdate")); <DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
} <DialogHeader className="pb-4">
}; <DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
const handleCreateBankRecordToggleChange = async (checked: boolean) => { <div className="flex-1 overflow-y-auto pr-2 min-h-0">
if (isGlobal) return; {showEditDialog && (
try { <CreatePositionForm
if (configItem?.id) { key={row.id + "-edit"}
await updateConfiguration({ mode="edit"
id: configItem.id, positionTypeId={row.id}
payload: { initialValues={{
organizationId: configItem.organizationId, nameAm: row.name.am,
positionTypeId: configItem.positionTypeId, nameEn: row.name.en,
timeframe: configItem.timeframe, unitId: row.unitId ?? "",
canCreateBankRecord: checked, key: row.key,
}, }}
}); onSuccess={async () => {
} else { setShowEditDialog(false);
await onToggle?.(row.id, checked, "canCreateBankRecord"); if (onEdit) {
} await onEdit();
toast.success(t("incomingRecord.UpdatedSuccessfully")); }
invalidateConfig(); }}
} catch (error) { onCancel={() => setShowEditDialog(false)}
handleError(error); />
toast.error(t("incomingRecord.FailedToUpdate")); )}
} </div>
}; </DialogContent>
const rowName = "name" in row ? row.name : { am: "", en: "" }; </Dialog>
const isPositionType = "name" in row && "key" in row;
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
return ( <AlertDialogContent>
<> <AlertDialogHeader>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<DropdownMenuTrigger asChild> <AlertDialogDescription>
<Button variant="ghost" className="h-8 w-8 p-0"> {t("common.DeleteConfirmationMessage", {
<MoreVertical className="h-4 w-4" /> name: localizedName(row.name),
<span className="sr-only">Open actions menu</span> })}
</Button> </AlertDialogDescription>
</DropdownMenuTrigger> </AlertDialogHeader>
<AlertDialogFooter>
<DropdownMenuContent <AlertDialogCancel disabled={deletePositionType.isPending}>
align="end" {t("common.Cancel")}
onInteractOutside={(e) => { </AlertDialogCancel>
const target = e.target as HTMLElement; <AlertDialogAction
if (!target.closest('[role="dialog"]')) { onClick={handleDelete}
setDropdownOpen(false); disabled={deletePositionType.isPending}
} className="bg-red-500 hover:bg-red-600">
}}> {deletePositionType.isPending
<DropdownMenuLabel>Actions</DropdownMenuLabel> ? t("common.Deleting")
: t("common.Delete")}
{canBeModified && ( </AlertDialogAction>
<DropdownMenuItem </AlertDialogFooter>
onSelect={handleMigrate} </AlertDialogContent>
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200"> </AlertDialog>
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" /> </>
<span> {t("common.Migrate")}</span> );
</DropdownMenuItem> };
)}
export default PositionTypeActionsCell;
{canBeModified && isPositionType && (
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Edit")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleView}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.View")}</span>
</DropdownMenuItem>
{canBeModified && (
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Delete")}</span>
</DropdownMenuItem>
)}
{/* Toggle moved here from ToggleCell */}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanReceiveRecord")}
</span>
<Switch
checked={isCanReceiveRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanAssignRecord")}
</span>
<Switch
checked={isCanAssignRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleAssignToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between gap-4">
<span className="text-sm">
{t("contentManagement.CanCreateBankRecord")}
</span>
<Switch
checked={isCanCreateBankRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleCreateBankRecordToggleChange}
/>
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{showMigrateDialog && (
<PositionTypeMigrationModal
isOpen={showMigrateDialog}
onClose={() => {
setShowMigrateDialog(false);
}}
toId={row.id}
toName={localizedName(rowName)}
positionTypeOptions={positionTypeOptions}
/>
)}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="pb-4">
<DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
{isPositionType && showEditDialog && (
<CreatePositionForm
key={row.id + "-edit"}
mode="edit"
positionTypeId={row.id}
initialValues={{
nameAm: row.name.am,
nameEn: row.name.en,
unitId: row.unitId,
key: row.key,
}}
onSuccess={async () => {
setShowEditDialog(false);
if (onEdit) {
await onEdit();
}
}}
onCancel={() => setShowEditDialog(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("common.DeleteConfirmationMessage", {
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600">
{isDeleting ? t("common.Deleting") : t("common.Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PositionTypeActionsCell;

View File

@@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => {
}; };
export const createPositionTypeColumns = ( export const createPositionTypeColumns = (
_positionTypeResponse?: PositionTypeResponse,
globalPositionTypes?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse,
onDelete?: () => void | Promise<void>, onDelete?: () => void | Promise<void>,
onEdit?: () => void | Promise<void>, onEdit?: () => void | Promise<void>,
onToggle?: (
positionTypeId: string,
checked: boolean,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
) => void | Promise<void>,
isGlobal?: boolean,
): ColumnDef<PositionTypeDto>[] => [ ): ColumnDef<PositionTypeDto>[] => [
{ {
accessorKey: "name", accessorKey: "name",
@@ -64,8 +57,6 @@ export const createPositionTypeColumns = (
globalPositionTypes={globalPositionTypes} globalPositionTypes={globalPositionTypes}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit} onEdit={onEdit}
onToggle={onToggle}
isGlobal={isGlobal}
/> />
), ),
}, },

View File

@@ -5,10 +5,14 @@ export interface PositionTypeDto {
en: string; en: string;
}; };
key: string; key: string;
unitId: string; /**
canReceiveRecord: boolean; * Null for the built-in ("common") types, which `isSystem` marks and which
canCreateBankRecord?: boolean; * every unit can use. IAM has no organizationId on a position type — the
canAssignRecord: boolean; * owning organization is only reachable via unit -> organizationId.
*/
unitId: string | null;
/** Built-in type. IAM rejects update/delete on these with a 403. */
isSystem?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { import {
CreatePositionTypePayload, CreatePositionTypePayload,
PositionRequest, PositionRequest,
@@ -23,10 +28,22 @@ interface positionParams {
interface UsePositionTypeManagerProps { interface UsePositionTypeManagerProps {
id?: string; id?: string;
unitId?: string; unitId?: string;
organizationId?: string;
params?: positionParams; // 👈 we expected query params to be passed like this params?: positionParams; // 👈 we expected query params to be passed like this
} }
/**
* Every cache key this hook writes under. React Query matches key prefixes
* element by element, so `["position-type"]` does NOT reach
* `["position-types-common", ...]` — each root has to be listed. Anything that
* mutates a position type should call this rather than hand-picking keys, or
* the department pickers (which read the "-common" queries) go stale.
*/
export const invalidatePositionTypeQueries = (queryClient: QueryClient) => {
["position-types", "position-type", "position-types-common"].forEach(
(root) => queryClient.invalidateQueries({ queryKey: [root] }),
);
};
export const usePositionTypes = ({ export const usePositionTypes = ({
id, id,
params = { params = {
@@ -35,11 +52,11 @@ export const usePositionTypes = ({
orderBy: "createdAt:Desc", orderBy: "createdAt:Desc",
}, },
unitId, unitId,
organizationId,
}: UsePositionTypeManagerProps = {}) => { }: UsePositionTypeManagerProps = {}) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
const invalidateAll = () => invalidatePositionTypeQueries(queryClient);
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["position-types", params], queryKey: ["position-types", params],
queryFn: () => positionTypeService.getAll(params).then((res) => res.data), queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
@@ -70,38 +87,6 @@ export const usePositionTypes = ({
enabled: !!unitId, enabled: !!unitId,
}); });
// Position types by organization ID
const {
data: positionTypeByOrgId,
isLoading: isLoadingOrgPosition,
isError: isErrorOrgPosition,
refetch: refetchOrgPosition,
} = useQuery<PositionTypesListResponse | undefined>({
queryKey: ["position-type-org", organizationId, params],
queryFn: async () => {
if (!organizationId) return undefined;
const res = await positionTypeService.getByOrganizationId(organizationId, params);
return res.data as PositionTypesListResponse | undefined;
},
enabled: !!organizationId,
});
// Common types with organization ID (includes both org-specific and common types)
const {
data: commonPositionTypesByOrgId,
isLoading: isLoadingCommonOrgTypes,
isError: isErrorCommonOrgTypes,
refetch: refetchCommonOrgTypes,
} = useQuery<PositionTypesListResponse | undefined>({
queryKey: ["position-types-common-org", organizationId, params],
queryFn: async () => {
if (!organizationId) return undefined;
const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params);
return res.data as PositionTypesListResponse | undefined;
},
enabled: !!organizationId,
});
// Common types with unit ID (includes both unit-specific and common types) // Common types with unit ID (includes both unit-specific and common types)
const { const {
data: commonPositionTypes, data: commonPositionTypes,
@@ -123,15 +108,16 @@ export const usePositionTypes = ({
mutationFn: (payload: CreatePositionTypePayload) => mutationFn: (payload: CreatePositionTypePayload) =>
positionTypeService.create(payload), positionTypeService.create(payload),
onSuccess: () => { onSuccess: () => {
toast.success("Position type created"); toast.success(t("contentManagement.positionTypeCreated"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
}, },
onError: (error) => { onError: (error) => {
handleError(error); handleError(error);
}, },
}); });
// Update // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in
// (isSystem) types, so the error has to reach the user.
const updatePositionType = useMutation({ const updatePositionType = useMutation({
mutationFn: ({ mutationFn: ({
id, id,
@@ -141,11 +127,12 @@ export const usePositionTypes = ({
data: UpdatePositionTypePayload; data: UpdatePositionTypePayload;
}) => positionTypeService.update(id, data), }) => positionTypeService.update(id, data),
onSuccess: () => { onSuccess: () => {
toast.success("Position type updated"); toast.success(t("contentManagement.positionTypeUpdated"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
//update positon from to //update positon from to
@@ -153,30 +140,32 @@ export const usePositionTypes = ({
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
positionTypeService.updateFromto(toId, fromId), positionTypeService.updateFromto(toId, fromId),
onSuccess: () => { onSuccess: () => {
toast.success("Position type migration updated"); toast.success(t("contentManagement.positionTypeMigrated"));
queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
//update all postions //update all postions
const migratePositionsByPositions = useMutation({ const migratePositionsByPositions = useMutation({
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
positionTypeService.updateByPostion(id, data), positionTypeService.updateByPostion(id, data),
onSuccess: () => { onSuccess: () => {
toast.success("Position type migration updated"); toast.success(t("contentManagement.positionTypeMigrated"));
queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
// Delete // Delete. Also 403s for built-in types.
const deletePositionType = useMutation({ const deletePositionType = useMutation({
mutationFn: (id: string) => positionTypeService.delete(id), mutationFn: (id: string) => positionTypeService.delete(id),
onSuccess: () => { onSuccess: () => {
toast.success("Position type deleted"); toast.success(t("contentManagement.positionTypeDeleted"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
}, },
onError: (error) => { onError: (error) => {
handleError(error); handleError(error);
@@ -205,16 +194,6 @@ export const usePositionTypes = ({
refetchPosition, refetchPosition,
isErrorPosition, isErrorPosition,
isLoadingPosition, isLoadingPosition,
// organization-based position types
positionTypeByOrgId,
refetchOrgPosition,
isErrorOrgPosition,
isLoadingOrgPosition,
// common types with organization ID
commonPositionTypesByOrgId,
refetchCommonOrgTypes,
isErrorCommonOrgTypes,
isLoadingCommonOrgTypes,
// common types with unit ID // common types with unit ID
commonPositionTypes: commonPositionTypes?.items ?? [], commonPositionTypes: commonPositionTypes?.items ?? [],
isLoadingCommonTypes, isLoadingCommonTypes,

View File

@@ -21,7 +21,7 @@ export interface PositionPayload {
organizationId: string; organizationId: string;
parentPositionId?: string; parentPositionId?: string;
projectId?: string; projectId?: string;
positionTypeId: string; positionTypeId?: string;
} }
export interface PositionQueryParams { export interface PositionQueryParams {
orderBy?: string; orderBy?: string;

View File

@@ -40,38 +40,25 @@ export const positionTypeService = {
getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> => getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> =>
axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }),
// Types owned by one unit. IAM has no organization-scoped route — position
// types carry a unitId only, so scoping to an org means filtering by that
// org's units client-side.
getByUnitId: ( getByUnitId: (
id: string, unitId: string,
params?: Params, params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> => ): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list/${id}`, { axiosInstance.get(`/position-types/list/${unitId}`, {
headers: withHeaders(),
params,
}),
getByOrganizationId: (
id: string,
params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list/${id}`, {
headers: withHeaders(),
params,
}),
getCommonTypesByOrganizationId: (
id: string,
params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list-with-commons/${id}`, {
headers: withHeaders(), headers: withHeaders(),
params, params,
}), }),
// WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in
// types, not the ones with a null unitId.
getCommonTypesById: ( getCommonTypesById: (
id: string, unitId: string,
params: Params, params: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> => ): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list-with-commons/${id}`, { axiosInstance.get(`/position-types/list-with-commons/${unitId}`, {
headers: withHeaders(), headers: withHeaders(),
params, params,
}), }),

View File

@@ -48,8 +48,6 @@ export function AddDepartmentForm({
if (!nameAm.trim()) if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired"); newErrors.nameAm = t("organization.amharicNameRequired");
if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired");
if (!positionTypeId)
newErrors.positionTypeId = t("contentManagement.selectPosType");
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
@@ -71,7 +69,7 @@ export function AddDepartmentForm({
key: key.trim().toLowerCase().replace(/\s+/g, "-"), key: key.trim().toLowerCase().replace(/\s+/g, "-"),
unitId, unitId,
organizationId, organizationId,
positionTypeId, ...(positionTypeId ? { positionTypeId } : {}),
}; };
createPosition({ createPosition({
@@ -90,12 +88,11 @@ export function AddDepartmentForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label> <Label htmlFor="positionType">
{t("organization.positionTypes")} ({t("common.optional")})
</Label>
<Select <Select
onValueChange={(value) => { onValueChange={setPositionTypeId}
setPositionTypeId(value);
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
}}
value={positionTypeId} value={positionTypeId}
disabled={isLoadingTypes} disabled={isLoadingTypes}
> >
@@ -110,9 +107,6 @@ export function AddDepartmentForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{errors.positionTypeId && (
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction"; import { ContractClearanceAction } from "./ContractClearanceAction";
@@ -70,17 +69,6 @@ export function ContractCustomerAction({
); );
} }
if (action.type === "pay-clearance") {
return (
<PayClearanceFeeButton
sourceId={action.contractId}
currency={contract.paymentCurrency}
label={action.label}
size={size}
/>
);
}
if (action.type === "initiate") { if (action.type === "initiate") {
return ( return (
<InitiateBookingButton <InitiateBookingButton

View File

@@ -80,14 +80,6 @@ export type ContractCustomerAction =
label: string; label: string;
primary: boolean; primary: boolean;
icon: LucideIcon; icon: LucideIcon;
}
| {
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
type: "pay-clearance";
contractId: string;
label: string;
primary: boolean;
icon: LucideIcon;
}; };
function findPayableBookingForContract( function findPayableBookingForContract(
@@ -145,17 +137,6 @@ export function deriveContractCustomerAction(
}; };
} }
// Prepaid clearance service fee gate — must settle before document upload.
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
return {
type: "pay-clearance",
contractId: id,
label: "Pay clearance fee",
primary: true,
icon: CreditCard,
};
}
const payable = findPayableBookingForContract(id, bookings); const payable = findPayableBookingForContract(id, bookings);
if (payable) { if (payable) {
return { return {

View File

@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
export interface ActionItem { export interface ActionItem {
id: string; id: string;
/** What the customer must do — drives the icon, label and modal. */ /** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee"; kind: "clearance" | "duty" | "sign" | "book" | "pay";
/** The contract/booking reference for display. */ /** The contract/booking reference for display. */
reference: string; reference: string;
/** Short human description of the action. */ /** Short human description of the action. */
@@ -40,18 +40,6 @@ export function deriveActionItems(
}); });
continue; continue;
} }
// Prepaid clearance service fee (Path B) — blocks the document step.
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${c.id}`,
kind: "clearance-fee",
reference: c.reference,
description: "Clearance service fee due — pay to unlock document upload",
targetId: c.id,
urgent: true,
});
continue;
}
const clr = contractNeedsClearanceAction(c); const clr = contractNeedsClearanceAction(c);
if (clr.show) { if (clr.show) {
items.push({ items.push({
@@ -82,19 +70,6 @@ export function deriveActionItems(
} }
for (const b of bookings) { for (const b of bookings) {
// Per-shipment clearance service fee (GENERAL + customs shipment request).
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${b.id}`,
kind: "clearance-fee",
reference: b.reference,
description:
"Clearance service fee due for this shipment — pay to unlock document upload",
targetId: b.id,
urgent: true,
});
continue;
}
const isGeneral = b.bookingType === "GENERAL_CONTRACT"; const isGeneral = b.bookingType === "GENERAL_CONTRACT";
const canPay = const canPay =
b.paymentStatus !== "PAID" && b.paymentStatus !== "PAID" &&

View File

@@ -39,7 +39,6 @@ const KIND_META: Record<
sign: { icon: FileSignature, label: "Sign", color: "blue" }, sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" }, book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" }, pay: { icon: CreditCard, label: "Payment", color: "orange" },
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
}; };
export interface ActionNeededSectionProps { export interface ActionNeededSectionProps {
@@ -138,14 +137,10 @@ export function ActionNeededSection({
// Billing is invoice-centric — resolve the booking's currently payable // Billing is invoice-centric — resolve the booking's currently payable
// invoice before paying it (mirrors ReadonlyBookingView). // invoice before paying it (mirrors ReadonlyBookingView).
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
// booking id depending on where the gate sits).
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
const { data: payItemInvoices = [] } = useQuery({ const { data: payItemInvoices = [] } = useQuery({
queryKey: [`${payItemSource}-invoices`, payItem?.targetId], queryKey: ["booking-invoices", payItem?.targetId],
queryFn: () => queryFn: () =>
invoicesService.listForSource(payItemSource, payItem!.targetId), invoicesService.listForSource("booking", payItem!.targetId),
enabled: payItem !== null, enabled: payItem !== null,
}); });
const payableInvoiceId = payItemInvoices.find((inv) => const payableInvoiceId = payItemInvoices.find((inv) =>
@@ -188,7 +183,6 @@ export function ActionNeededSection({
navigate(`/contracts/${item.targetId}`); navigate(`/contracts/${item.targetId}`);
break; break;
case "pay": case "pay":
case "clearance-fee":
setPayItem(item); setPayItem(item);
break; break;
case "sign": case "sign":
@@ -284,9 +278,7 @@ export function ActionNeededSection({
> >
{item.kind === "pay" {item.kind === "pay"
? "Pay now" ? "Pay now"
: item.kind === "clearance-fee" : item.kind === "duty"
? "Pay clearance fee"
: item.kind === "duty"
? "Pay duty & upload slip" ? "Pay duty & upload slip"
: item.kind === "sign" : item.kind === "sign"
? "Sign" ? "Sign"

View File

@@ -165,19 +165,6 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5", badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" }, action: { label: "View", kind: "outline" },
}, },
AWAITING_CLEARANCE_PAYMENT: {
stage: 3,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Clearance service fee due · pay to unlock document upload",
step: "edr-accent",
badgeLabel: "Clearance fee due",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
},
AWAITING_DOCUMENTS: { AWAITING_DOCUMENTS: {
stage: 3, stage: 3,
icon: FileUp, icon: FileUp,

View File

@@ -1,4 +1,4 @@
import { Group, Paper, Tabs, Text } from "@mantine/core"; import { Group, Tabs } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -12,7 +12,6 @@ import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
import { ActivityCard } from "./components/ActivityCard"; import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard"; import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab"; import { DocumentsTab } from "./components/DocumentsTab";
@@ -143,8 +142,6 @@ export function ReadonlyBookingView({
const isCustoms = Boolean(booking.customsClearingEnabled); const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms; const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Prepaid clearance service fee gate — document upload stays locked until paid.
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
const isClearance = [ const isClearance = [
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
@@ -243,28 +240,6 @@ export function ReadonlyBookingView({
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<ContractCard booking={booking} /> <ContractCard booking={booking} />
{isAwaitingClearanceFee && (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<div>
<Text fw={700} fz={15} c="#92400E">
Customs clearance service fee due
</Text>
<Text fz={13} c="#B45309" mt={4}>
Pay the clearance service fee to unlock the clearance
document upload. Global Logistics starts working on your
shipment once the fee is settled.
</Text>
</div>
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
size="md"
/>
</Group>
</Paper>
)}
{isClearance && <ClearanceCard booking={booking} />} {isClearance && <ClearanceCard booking={booking} />}
<BodyGrid <BodyGrid

View File

@@ -51,17 +51,10 @@ export function buildJourneySteps(
if (isCustoms && milestones.length > 0) { if (isCustoms && milestones.length > 0) {
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder); const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id; const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
const feeActive = status === "AWAITING_CLEARANCE_PAYMENT";
const delivered = ["COMPLETED", "DELIVERED"].includes(status); const delivered = ["COMPLETED", "DELIVERED"].includes(status);
const steps: JourneyStep[] = [ const steps: JourneyStep[] = [
{ key: "booked", label: "Booking initiated", state: "done" }, { key: "booked", label: "Booking initiated", state: "done" },
{
key: "fee",
label: "Clearance fee paid",
state: feeActive ? "active" : "done",
owner: "CUST",
},
...sorted.map<JourneyStep>((m) => ({ ...sorted.map<JourneyStep>((m) => ({
key: m.id, key: m.id,
label: m.milestoneLabel, label: m.milestoneLabel,
@@ -71,7 +64,7 @@ export function buildJourneySteps(
? "done" ? "done"
: m.status === "SKIPPED" : m.status === "SKIPPED"
? "skipped" ? "skipped"
: !feeActive && m.id === firstPendingId : m.id === firstPendingId
? "active" ? "active"
: "idle", : "idle",
})), })),
@@ -79,7 +72,7 @@ export function buildJourneySteps(
]; ];
// Every known milestone is done but the booking hasn't closed yet — the // Every known milestone is done but the booking hasn't closed yet — the
// delivery step is what's in progress. // delivery step is what's in progress.
if (!feeActive && !firstPendingId && !delivered) { if (!firstPendingId && !delivered) {
steps[steps.length - 1].state = "active"; steps[steps.length - 1].state = "active";
} }
return steps; return steps;

View File

@@ -3,7 +3,6 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
CreditCard,
PackagePlus, PackagePlus,
PencilLine, PencilLine,
Upload, Upload,
@@ -12,7 +11,6 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal"; import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
import { BookingActionModal } from "./BookingActionModal"; import { BookingActionModal } from "./BookingActionModal";
@@ -25,7 +23,6 @@ const ICON_BY_KIND: Record<
BookingActionKind, BookingActionKind,
typeof Upload typeof Upload
> = { > = {
PAY_CLEARANCE: CreditCard,
UPLOAD_DOCUMENTS: Upload, UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle, FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight, SCHEDULE_OPERATION: ArrowRight,
@@ -59,19 +56,6 @@ export function BookingActionButton({
if (!isChangesRequested && !action) return null; if (!isChangesRequested && !action) return null;
// The prepaid clearance service fee has its own payment flow (method modal +
// provider redirect) — delegate to the self-contained pay button.
if (action?.kind === "PAY_CLEARANCE") {
return (
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
label={action.label}
size={size}
/>
);
}
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit"; const label = action ? action.label : "Update & resubmit";
// BOOK navigates to the booking form (cargo + day + window check) — the // BOOK navigates to the booking form (cargo + day + window check) — the

View File

@@ -7,7 +7,6 @@ import type { Freight } from "@edr/types";
* to operation. * to operation.
*/ */
export type BookingActionKind = export type BookingActionKind =
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
@@ -24,11 +23,6 @@ export interface BookingNextAction {
} }
const ACTION_BY_STATUS: Record<string, BookingNextAction> = { const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
AWAITING_CLEARANCE_PAYMENT: {
kind: "PAY_CLEARANCE",
label: "Pay clearance fee",
title: "Pay the clearance service fee",
},
AWAITING_DOCUMENTS: { AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS", kind: "UPLOAD_DOCUMENTS",
label: "Upload documents", label: "Upload documents",

View File

@@ -1,135 +0,0 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard } from "lucide-react";
import { useState } from "react";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { isPayable } from "@/pages/billing/invoice-ui";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
/**
* Payment flow for the prepaid customs clearance service fee. The fee is its
* own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
* contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
* shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
* unlocks the clearance document upload; same modal + provider redirect as
* booking payment.
*/
export function useClearanceFeePayment(sourceId: string) {
const [modalOpen, setModalOpen] = useState(false);
const { data: invoices = [] } = useQuery({
queryKey: ["clearance-invoices", sourceId],
queryFn: () => invoicesService.listForSource("clearance", sourceId),
enabled: Boolean(sourceId),
});
const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
const mutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoice) {
throw new Error(
"No payable clearance-fee invoice found yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoice.id,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoice!.id,
method,
});
window.location.href = redirectUrl;
},
});
const close = () => {
if (!mutation.isPending) {
setModalOpen(false);
mutation.reset();
}
};
return {
invoice: payableInvoice,
modalOpen,
open: () => setModalOpen(true),
close,
processing: mutation.isPending,
error: mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null,
confirm: (method: PaymentMethod) => mutation.mutate(method),
};
}
interface PayClearanceFeeButtonProps {
/** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
sourceId: string;
/** Fallback currency while the invoice is loading. */
currency?: string;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
export function PayClearanceFeeButton({
sourceId,
currency,
label = "Pay clearance fee",
size = "xs",
fullWidth,
}: PayClearanceFeeButtonProps) {
const pay = useClearanceFeePayment(sourceId);
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={
pay.invoice
? `${Number(
pay.invoice.balanceAmount ?? pay.invoice.totalAmount,
).toLocaleString()} ${pay.invoice.currency}`
: undefined
}
currency={pay.invoice?.currency ?? currency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action"; import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -438,9 +437,6 @@ export default function ContractDetailPage() {
// clearance is finalized. // clearance is finalized.
const canUploadClearance = const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
// Prepaid clearance service fee gate (Path B) — the document step stays
// locked until the fee invoice settles.
const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
return ( return (
<Box style={{ padding: "28px 32px 40px" }}> <Box style={{ padding: "28px 32px 40px" }}>
@@ -574,13 +570,6 @@ export default function ContractDetailPage() {
Global Logistics is creating your booking Global Logistics is creating your booking
</Badge> </Badge>
)} )}
{awaitingClearanceFee && (
<PayClearanceFeeButton
sourceId={contract.id}
currency={contract.paymentCurrency}
size="md"
/>
)}
{canUploadClearance && ( {canUploadClearance && (
<Button <Button
color="edr-green" color="edr-green"
@@ -935,8 +924,8 @@ export default function ContractDetailPage() {
)} )}
{item.isClearance && ( {item.isClearance && (
<Text fz={12} c="orange.7" fw={600}> <Text fz={12} c="orange.7" fw={600}>
Paid in advance, before clearance excluded from Customs service fee billed on your shipment booking
shipment invoices invoice together with the freight
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -19,6 +19,7 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { import {
AlertCircle, AlertCircle,
Check, Check,
@@ -218,6 +219,12 @@ export default function NewContractPage({
}, },
}); });
// 422 from generate-price = a required rate isn't configured for the chosen
// route — the customer can't fix it, so it's surfaced as a blocking modal.
const rateNotConfigured =
isAxiosError(persistAndPriceMutation.error) &&
persistAndPriceMutation.error.response?.status === 422;
const confirmMutation = useMutation({ const confirmMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!priceContractId) throw new Error("No contract to confirm"); if (!priceContractId) throw new Error("No contract to confirm");
@@ -759,7 +766,7 @@ export default function NewContractPage({
<StepIndicator step={step} steps={visibleSteps} /> <StepIndicator step={step} steps={visibleSteps} />
</Box> </Box>
{persistAndPriceMutation.isError && ( {persistAndPriceMutation.isError && !rateNotConfigured && (
<Alert <Alert
color="red" color="red"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
@@ -777,6 +784,28 @@ export default function NewContractPage({
</Alert> </Alert>
)} )}
{/* Pricing not configured (422) — a rate is missing on the chosen
route, nothing the customer can fix. Block with a modal. */}
<Modal
opened={rateNotConfigured}
onClose={() => persistAndPriceMutation.reset()}
title="Contract creation unavailable"
centered
>
<Stack gap="sm">
<Text size="sm">
You can&apos;t create a contract right now pricing hasn&apos;t
been configured for the selected route yet. Please contact
support for assistance.
</Text>
<Group justify="flex-end">
<Button onClick={() => persistAndPriceMutation.reset()}>
OK
</Button>
</Group>
</Stack>
</Modal>
{/* Step 0 — Setup: operation, contract, service, currency, miles. */} {/* Step 0 — Setup: operation, contract, service, currency, miles. */}
{step === 0 && ( {step === 0 && (
<StepCard> <StepCard>
@@ -964,8 +993,8 @@ export default function NewContractPage({
)} )}
{item.isClearance && ( {item.isClearance && (
<Text size="xs" c="orange.7" fw={600}> <Text size="xs" c="orange.7" fw={600}>
Paid in advance, before clearance not part of your Customs service fee billed on your shipment booking
shipment booking invoice invoice together with the freight
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -125,10 +125,6 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success }, FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
CONTRACT_ACTIVE: { label: "Active", ...TONE.success }, CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
// ── Path B pre-booking clearance (contract-level) ── // ── Path B pre-booking clearance (contract-level) ──
AWAITING_CLEARANCE_PAYMENT: {
label: "Clearance Fee Due",
...TONE.warning,
},
AWAITING_CLEARANCE_DOCUMENTS: { AWAITING_CLEARANCE_DOCUMENTS: {
label: "Upload Clearance Docs", label: "Upload Clearance Docs",
...TONE.warning, ...TONE.warning,

View File

@@ -4,6 +4,7 @@ import type { Freight } from "@edr/types";
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
per_container: "container", per_container: "container",
per_wagon: "wagon",
per_ton: "ton", per_ton: "ton",
per_item: "item", per_item: "item",
per_km: "km", per_km: "km",

View File

@@ -48,8 +48,12 @@ export function computeShipmentTotal(
(i) => (i) =>
i.containerSize === line.containerSize && i.containerSize === line.containerSize &&
i.unit === "per_container" && i.unit === "per_container" &&
!i.conditionalOn, !i.conditionalOn &&
) ?? rateFor((i) => i.containerSize === line.containerSize); !i.isClearance,
) ??
rateFor(
(i) => i.containerSize === line.containerSize && !i.isClearance,
);
if (rate) { if (rate) {
lines.push({ lines.push({
label: rate.label, label: rate.label,
@@ -106,7 +110,12 @@ export function computeShipmentTotal(
} else { } else {
const qty = Number(values.cargoWeightTons || values.itemCount || 0); const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate = const rate =
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; rateFor(
(i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
if (rate && qty > 0) { if (rate && qty > 0) {
lines.push({ lines.push({
label: rate.label, label: rate.label,
@@ -144,6 +153,53 @@ export function computeShipmentTotal(
} }
} }
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = Number(values.cargoWeightTons || 0);
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
// the wagon capacity the train stocks — shown at real pricing, not estimated.
for (const cl of items.filter((i) => i.isClearance)) {
let qty = 0;
if (isContainer) {
const boxes = (values.containers ?? [])
.filter((c) => c.containerSize === cl.containerSize)
.reduce((s, c) => s + Number(c.quantity || 0), 0);
qty =
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
qty = Number(values.cargoWeightTons || 0);
} else if (cl.unit === "flat") {
qty = 1;
}
if (qty > 0) {
lines.push({
label: cl.label,
unitPrice: cl.unitPrice,
unit: cl.unit,
quantity: qty,
amount: cl.unitPrice * qty,
});
}
}
const total = lines.reduce((s, l) => s + l.amount, 0); const total = lines.reduce((s, l) => s + l.amount, 0);
return { currency, lines, total }; return { currency, lines, total };
} }

View File

@@ -165,6 +165,64 @@ WHERE NOT EXISTS (
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0') SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
); );
-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the
-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at
-- KALITY behind two dedicated locos. A built train's physical wagon count IS
-- its schedule capacity (37), immune to the loco-length slot recompute.
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.cargo_types ct
JOIN freight.wagon_types wt ON wt.code = 'PW2'
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
AND NOT EXISTS (
SELECT 1 FROM freight.cargo_type_wagon_types x
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
);
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code)
JOIN freight.yards y ON y.code = 'KALITY'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
INSERT INTO freight.trains
(id, code, train_name, capacity_tons, current_yard_id,
import_train_number, export_train_number)
SELECT gen_random_uuid(), 'TRN-LEDGER-PW2', 'Ledger Grain Carrier', 2600, y.id,
'9102', '9101'
FROM freight.yards y
WHERE y.code = 'KALITY'
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-LEDGER-PW2');
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
SELECT gen_random_uuid(), t.id, l.id,
row_number() OVER (ORDER BY l.code) - 1
FROM freight.trains t
JOIN freight.locomotives l ON l.code IN ('LOCO-LED-1', 'LOCO-LED-2')
WHERE t.code = 'TRN-LEDGER-PW2'
AND NOT EXISTS (
SELECT 1 FROM freight.train_locomotives tl
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
);
UPDATE freight.wagons w
SET train_id = t.id,
sequence_number = sub.rn,
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
FROM freight.trains t,
LATERAL (
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'PW2'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number
LIMIT 37
) sub
WHERE t.code = 'TRN-LEDGER-PW2'
AND w.id = sub.id
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
-- 5c. Approved exporter profile — export contracts bill against it -- 5c. Approved exporter profile — export contracts bill against it
-- (seed-company.sql only creates the importer). -- (seed-company.sql only creates the importer).
INSERT INTO freight.company_profiles (id, company_id, type, status, reference) INSERT INTO freight.company_profiles (id, company_id, type, status, reference)

View File

@@ -42,7 +42,6 @@ export const CONTRACT_STATUSES = [
"FULLY_EXECUTED", // ONE_TIME "FULLY_EXECUTED", // ONE_TIME
"CONTRACT_ACTIVE", // GENERAL "CONTRACT_ACTIVE", // GENERAL
// customs clearance execution (Path B, pre-booking) // customs clearance execution (Path B, pre-booking)
"AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid
"AWAITING_CLEARANCE_DOCUMENTS", "AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW", "CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", "CLEARANCE_READY_FOR_BOOKING",
@@ -68,7 +67,6 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
*/ */
export const CONTRACT_CLEARANCE_STATUSES = [ export const CONTRACT_CLEARANCE_STATUSES = [
"NOT_APPLICABLE", "NOT_APPLICABLE",
"AWAITING_PAYMENT", // Path B — clearance service fee must be paid first
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking "CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
@@ -89,6 +87,7 @@ export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
export type ContractRateUnit = export type ContractRateUnit =
| "per_container" | "per_container"
| "per_wagon"
| "per_ton" | "per_ton"
| "per_item" | "per_item"
| "per_km" | "per_km"

View File

@@ -103,8 +103,6 @@ export enum BookingStatus {
PendingConsolidation = "PENDING_CONSOLIDATION", PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED", Consolidated = "CONSOLIDATED",
// Post counter-sign document-clearance gate (GL workflow). // Post counter-sign document-clearance gate (GL workflow).
/** Clearance service fee invoiced; docs + GL work locked until paid. */
AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT",
AwaitingDocuments = "AWAITING_DOCUMENTS", AwaitingDocuments = "AWAITING_DOCUMENTS",
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW", DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
ClearanceReady = "CLEARANCE_READY", ClearanceReady = "CLEARANCE_READY",