Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-21 15:50:35 +03:00
119 changed files with 4102 additions and 651 deletions

View File

@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { assertExportReceivedWithGrn } from './export-received-gate';
const db = (rows: unknown[]) =>
({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource;
describe('assertExportReceivedWithGrn', () => {
it('passes when the export booking has a received row with a GRN', async () => {
await expect(
assertExportReceivedWithGrn(db([{ '?column?': 1 }]), {
id: 'b-1',
tradeDirection: 'EXPORT',
}),
).resolves.toBeUndefined();
});
it('rejects an export booking with nothing received', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('never blocks import — it loads off a train, not out of the warehouse', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }),
).resolves.toBeUndefined();
// Import short-circuits before querying.
expect((source.query as jest.Mock)).not.toHaveBeenCalled();
});
it('does not block intercity cargo', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
/** The booking fields the gate needs. */
export interface ExportLoadGateBooking {
id: string;
tradeDirection?: string | null;
}
/**
* Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by
* the customer's own truck, and even though a wagon is already allocated. An
* allocation is a plan; the GRN is the proof the goods are actually in hand.
*
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
* loaded straight off the allocation, skipping the warehouse, so a booking could
* ride the train with nothing ever received. This closes that for export; import
* loads off a train and is unaffected.
*
* "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use.
*/
export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager,
booking: ExportLoadGateBooking,
): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return;
const [row] = await db.query(
`SELECT 1
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
LIMIT 1`,
[booking.id],
);
if (!row) {
throw new BadRequestException(
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
);
}
}

View File

@@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Configured rail distance between two yards (Configuration → Yard Distances).
* Route creation resolves each segment's km from here (symmetric lookup:
* one A↔B row serves both directions) instead of accepting free-text km,
* and snapshots the value onto route_milestones.distance_km.
*
* Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair
* can be re-created.
*/
export class CreateYardDistances2060000000000 implements MigrationInterface {
name = 'CreateYardDistances2060000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_distances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
from_yard_id uuid NOT NULL REFERENCES freight.yards(id),
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
distance_km numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard
ON freight.yard_distances (from_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard
ON freight.yard_distances (to_yard_id);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair
ON freight.yard_distances (from_yard_id, to_yard_id)
WHERE deleted_at IS NULL;
`);
// Backfill from segments already stored on existing routes so editing them
// does not immediately fail the "pair not configured" check. One row per
// unordered pair; where routes disagree the longest segment wins.
await queryRunner.query(`
INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km)
SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id))
prev_yard_id, yard_id, distance_km
FROM (
SELECT
yard_id,
distance_km,
LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id
FROM freight.route_milestones
WHERE deleted_at IS NULL
) segments
WHERE prev_yard_id IS NOT NULL
AND distance_km IS NOT NULL
AND distance_km > 0
ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC
ON CONFLICT DO NOTHING;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-document review state, so a backoffice reviewer can request a correction
* on one specific onboarding document instead of rejecting the whole role.
*
* Until now `freight.files` carried no status at all: the `pending_add` /
* `pending_remove` badges the portal shows are derived by diffing live rows
* against an open company change request, which says nothing about whether a
* reviewer is happy with a given document. `review_status` is that missing
* verdict — NULL means never reviewed, which is the state every existing row
* correctly starts in, so no backfill is needed.
*
* The partial index serves the approval gate, which asks "does this company (or
* profile) still have any document with an open change request?" on every
* role-status write.
*/
export class AddFileReviewStatus2430000000000 implements MigrationInterface {
name = 'AddFileReviewStatus2430000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL,
ADD COLUMN IF NOT EXISTS review_note text NULL,
ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL,
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request"
ON freight.files (resource, resource_id)
WHERE review_status = 'change_requested' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`,
);
await queryRunner.query(`
ALTER TABLE freight.files
DROP COLUMN IF EXISTS review_status,
DROP COLUMN IF EXISTS review_note,
DROP COLUMN IF EXISTS reviewed_by,
DROP COLUMN IF EXISTS reviewed_at
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
* customer_truck_assignments (self-haul only), so EDR trucks need their own
* link to the last-mile vehicle assignment that hauled the goods. Generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal — one per truck, or booking-level (both ids null)
* when the truck cannot be resolved.
*/
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
name = 'AddHandoverEdrAssignment2440000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
ON freight.booking_handovers (booking_id, edr_assignment_id)
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
);
}
}

View File

@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
});
}
/** Intercity documents approved → booking waits in the ride-along pool. */
intercityDocumentsApproved(b: Booking): void {
const msg =
`Documents for intercity booking ${b.reference} are approved. ` +
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
this.inApp(b, 'Documents approved', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =

View File

@@ -137,7 +137,7 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
@@ -247,7 +247,7 @@ export class BookingPricingService {
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: ruleResult.hardBlocked,
overweightLines,
};
@@ -454,7 +454,7 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
@@ -476,6 +476,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
@@ -487,47 +488,63 @@ export class BookingPricingService {
booking.originYardId,
booking.destinationYardId,
);
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const unitUsd = Number(rate.rateValue);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
// It also stands on its own: a contract line prices off the agreed rate
// even when nobody configured a live rate for this leg + type yet.
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate — an unpriced line with a warning is recoverable; a
// silently mischarged one is not.
warnings.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
'the line was not priced.',
);
continue;
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
rate.rateUnit,
rateUnit,
unitAmount,
container.quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
const label = await this.containerTypeLabel(container.containerTypeId);
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
});
}
if (lines.length === 0) {
if (lines.length === 0 && evalInput.containers.length === 0) {
// Bulk (and any booking with no container lines) still has to price off a
// rate configured for this leg — never one belonging to another route.
// Container bookings never reach this fallback: their lines price per
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
(r) =>
r.rateType === rateType &&
@@ -573,7 +590,7 @@ export class BookingPricingService {
}
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
}
/**

View File

@@ -840,6 +840,22 @@ export class BookingTransitionService {
}
}
// Intercity: there is no shipment-day request step — an approved booking
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
// pool keys on. Staff then accept it onto a passing train (that accept
// opens the pay window).
if (booking.tradeDirection === "DOMESTIC") {
const now = new Date();
await this.bookingsRepository.update(bookingId, {
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.intercityDocumentsApproved(fresh);
return fresh;
}
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);

View File

@@ -1,7 +1,10 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
clearanceCodesForBooking,
INTERCITY_DOCUMENTS_SETTING_CODE,
} from './clearance.util';
import type { Booking } from './entities/booking.entity';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
});
});
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
const base = {
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
serviceType: null,
customsClearingEnabled: false,
};
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
const general = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'GENERAL',
} as unknown as Booking);
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(general.outputCode).toBeNull();
const direct = clearanceCodesForBooking({
...base,
contractId: null,
contractKind: null,
} as unknown as Booking);
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
return null; // DOMESTIC / intercity — no customs operation
}
function freightFor(freightType: string): Freight {
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
freightType: string,
includesCustoms: boolean,
): string | null {
// Intercity: no customs, but the admin-configured intercity document set is
// still collected and ops-reviewed before the shipment may board a train.
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -494,6 +495,9 @@ export class CompaniesController {
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
reviewStatus: f.reviewStatus,
reviewNote: f.reviewNote,
reviewedAt: f.reviewedAt,
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
// it so the file previews/downloads in the client.
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
@@ -501,6 +505,35 @@ export class CompaniesController {
);
}
@Post("documents/:fileId/request-change")
@FreightAdmin()
@ApiOperation({
summary: "Ask the customer to correct one uploaded document",
description:
"Flags a single document with a reason the customer sees, notifies them, " +
"and blocks role approval until they re-upload. Narrower than rejecting " +
"the whole role.",
})
async requestDocumentChange(
@CurrentUser() user: CurrentIamUser,
@Param("fileId", ParseUUIDPipe) fileId: string,
@Body() dto: RequestDocumentChangeDto,
) {
const file = await this.companiesService.requestDocumentChange(
fileId,
dto.note,
user.id,
);
return {
id: file.id,
name: file.name,
code: file.code,
reviewStatus: file.reviewStatus,
reviewNote: file.reviewNote,
reviewedAt: file.reviewedAt,
};
}
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")

View File

@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
)
)`;
/**
* A company waiting on a reviewer to decide an edit it submitted after being
* approved. These rows are `status = active`, so the pending-application filter
* can never surface them — the review queue needs its own predicate.
*/
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = company.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -67,6 +79,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
kind,
status,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'name',
sortOrder = 'ASC',
} = query;
@@ -99,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
);
}
if (hasPendingChangeRequest !== undefined) {
qb.andWhere(
hasPendingChangeRequest
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -143,6 +164,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const pendingChanges = await this.repository
.createQueryBuilder('company')
.where('company.deleted_at IS NULL')
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
.getCount();
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
@@ -160,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
pendingChanges,
};
}
}

View File

@@ -5,6 +5,7 @@ import {
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -98,6 +99,7 @@ export class CompaniesService {
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
) { }
/**
@@ -748,7 +750,15 @@ export class CompaniesService {
submittedAt: now,
note: null,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
// Rejecting a request leaves it Rejected rather than reopening it, so a
// customer amending after a rejection lands here with a fresh Pending row.
// That is the resubmission case the reviewer needs flagged.
const history = await this.changeRequestRepo.findByCompanyId(company.id);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
@@ -756,6 +766,11 @@ export class CompaniesService {
submittedBy: userId,
submittedAt: now,
});
this.companyNotifier.changeRequestSubmitted(
company,
request.id,
resubmitted,
);
}
// Live company is unchanged; surface the pending state for the settings page.
@@ -827,6 +842,12 @@ export class CompaniesService {
"companies",
files,
);
await this.resolveDocumentChangeRequests(
companyId,
"companies",
uploaded.map((f) => f.code),
uploaded.map((f) => f.id),
);
if (company.status === CompanyStatus.Active) {
await this.stageDocumentChange(
company.id,
@@ -837,6 +858,95 @@ export class CompaniesService {
return uploaded;
}
/**
* Clear the `change_requested` flag from the documents a fresh upload replaces.
*
* Uploading does not overwrite the old row — it adds a new one under the same
* `code` — so the flagged original would otherwise linger and keep the approval
* gate closed even after the customer did exactly what was asked. Only rows of
* the same code are touched, and never the newly uploaded ones.
*/
private async resolveDocumentChangeRequests(
resourceId: string,
resource: string,
codes: string[],
uploadedIds: string[],
): Promise<void> {
if (codes.length === 0) return;
const replaced = new Set(codes);
const fresh = new Set(uploadedIds);
const open = await this.filesService.findWithOpenChangeRequest(
[resourceId],
resource,
);
await Promise.all(
open
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
.map((f) => this.filesService.clearReview(f.id)),
);
}
/**
* Backoffice: ask the customer to correct one specific document, instead of
* rejecting their whole role over it. Mirrors the contract change-request
* flow — a note the customer sees verbatim, plus a block on approval until
* they re-upload.
*/
async requestDocumentChange(
fileId: string,
note: string,
reviewerId?: string,
): Promise<FileRecord> {
const file = await this.filesService.findById(fileId);
const companyId = await this.resolveDocumentCompanyId(file);
const company = await this.findCompanyById(companyId);
// Flag the document while holding a write lock on its company row. The
// approval gate takes the same lock before it reads the flags, so the two
// serialize: a change request can never land in the window between the gate
// checking "any open corrections?" and writing the profile Active.
const updated = await this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: companyId },
lock: { mode: "pessimistic_write" },
});
return this.filesService.setReviewStatus(
file.id,
"change_requested",
note,
reviewerId,
);
});
this.companyNotifier.documentChangeRequested(
company,
file.name,
note,
file.id,
);
return updated;
}
/**
* Which company a stored document belongs to. Company documents are keyed by
* the company id directly; profile licences and POA letters hang off a company
* profile, so those resolve through it.
*/
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
if (file.resource === "companies") return file.resourceId;
if (file.resource === "company_profiles") {
const profile = await this.companyProfilesRepo.findById(file.resourceId);
if (!profile) {
throw new NotFoundException(
`Company profile ${file.resourceId} not found`,
);
}
return profile.companyId;
}
throw new BadRequestException(
`Documents on "${file.resource}" do not support change requests`,
);
}
/** Open or append a pending change request recording staged document uploads. */
private async stageDocumentChange(
companyId: string,
@@ -847,6 +957,7 @@ export class CompaniesService {
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const company = await this.companiesRepo.findById(companyId);
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
@@ -860,8 +971,15 @@ export class CompaniesService {
submittedAt: now,
note: null,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
}
} else {
await this.changeRequestRepo.create({
const history = await this.changeRequestRepo.findByCompanyId(companyId);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
const created = await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentFileIds: fileIds },
@@ -869,6 +987,13 @@ export class CompaniesService {
submittedBy: submittedBy ?? null,
submittedAt: now,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(
company,
created.id,
resubmitted,
);
}
}
}
@@ -987,6 +1112,61 @@ export class CompaniesService {
}
}
// Anything other than approval has no document gate and no concurrency
// hazard — apply it directly.
if (status !== ProfileStatus.Active) {
return this.applyProfileStatus(existing, status, note, reviewerId);
}
// Approving over an outstanding document correction would silently accept the
// very document a reviewer just rejected, and would strand the customer's
// "please fix this" banner with nothing left to fix. The gate check and the
// status write share a write lock on the company row — `requestDocumentChange`
// takes the same lock, so a fresh correction can never land in the window
// between "any open corrections?" and the profile going Active. Suspend and
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
"companies",
),
this.filesService.findWithOpenChangeRequest(
[existing.id],
"company_profiles",
),
]);
const pending = [...companyDocs, ...profileDocs];
if (pending.length > 0) {
const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
);
}
return this.applyProfileStatus(existing, status, note, reviewerId);
});
}
/**
* Write a reviewed profile status (reference minting, note handling, reviewer
* stamp) and promote the company if this is its first approved role. Split out
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
* transaction while every other status skips that overhead.
*/
private async applyProfileStatus(
existing: CompanyProfile,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
@@ -1008,9 +1188,9 @@ export class CompaniesService {
patch.reviewedAt = new Date();
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
const updated = await this.companyProfilesRepo.update(existing.id, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
throw new NotFoundException(`Company profile ${existing.id} not found`);
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
@@ -1057,6 +1237,13 @@ export class CompaniesService {
});
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
// The role is back in the pending queue — tell the reviewers, otherwise the
// resubmission is invisible until someone happens to reopen the customer.
const company = await this.companiesRepo.findById(companyId);
if (company) {
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
}
return updated;
}
@@ -1512,6 +1699,15 @@ export class CompaniesService {
);
}
// A fresh licence upload answers any correction the reviewer asked for on the
// previous one, so the old row must stop blocking approval.
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
uploaded.map((r) => r.id),
);
return this.getProfileLicenseView(profileId, company.id);
}
@@ -1593,6 +1789,13 @@ export class CompaniesService {
await this.filesService.remove(fileId);
}
await this.resolveDocumentChangeRequests(
profileId,
LICENSE_RESOURCE,
[LICENSE_CODE, LICENSE_PENDING_CODE],
[created.id],
);
return this.getProfileLicenseView(profileId, company.id);
}
@@ -1689,6 +1892,8 @@ export class CompaniesService {
: pendingRemoveIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}
@@ -1855,6 +2060,13 @@ export class CompaniesService {
for (const r of live) await this.filesService.remove(r.id);
}
await this.resolveDocumentChangeRequests(
company.id,
COMPANY_RESOURCE,
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
[created.id],
);
return this.getPoaDelegationView(company.id);
}
@@ -1932,6 +2144,8 @@ export class CompaniesService {
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
reviewStatus: r.reviewStatus,
reviewNote: r.reviewNote,
}));
}

View File

@@ -88,4 +88,106 @@ export class CompanyNotifierService {
priority: NotificationPriority.HIGH,
});
}
// ── Backoffice-facing: work has arrived back in the review queue ────────────
/**
* Persist + push an in-app item to every backoffice staff user, deep-linked to
* the customer's detail page.
*
* The recipient resolver has no role/permission targeting (see
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
* selector available, so marketing is reached by notifying all staff.
*/
private notifyStaff(
company: Company,
title: string,
body: string,
data: Record<string, unknown> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/customers/${company.id}`,
data: { companyId: company.id, companyName: company.name, ...data },
});
}
/**
* A customer resubmitted an operational role after it was rejected for
* adjustment. Without this the role silently flips back to Pending and nobody
* is told there is anything to look at again.
*/
roleReapplied(company: Company, profileId: string, profileType: string): void {
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
this.notifyStaff(
company,
"Customer resubmitted a role for approval",
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
`It is back in the pending approval queue for review.`,
{ profileId, profileType },
);
}
/**
* A customer submitted (or amended and resubmitted) a profile change request.
* `resubmitted` distinguishes the two so the reviewer knows this is a second
* look at something they already sent back.
*/
changeRequestSubmitted(
company: Company,
changeRequestId: string,
resubmitted: boolean,
): void {
this.logger.log(
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"}${company.id}`,
);
this.notifyStaff(
company,
resubmitted
? "Customer resubmitted profile changes"
: "Customer submitted profile changes",
resubmitted
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
`them. They are pending your review.`
: `${company.name} has submitted profile changes that are pending review.`,
{ changeRequestId },
);
}
// ── Customer-facing: a specific document needs correcting ──────────────────
/**
* Tell the customer a reviewer wants one specific document corrected. Mirrors
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
* deep-linked to the documents tab where they can re-upload.
*/
documentChangeRequested(
company: Company,
documentName: string,
note: string,
fileId: string,
): void {
const title = "Document change requested";
const body =
`A reviewer has asked you to correct "${documentName}". ` +
`Reason: ${note} ` +
`Please upload a corrected version from your settings page.`;
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title,
body,
link: "/settings",
data: { companyId: company.id, fileId, documentName },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -7,4 +7,10 @@ export class CompanyStatsResponseDto {
onboarding!: number;
suspended!: number;
blacklisted!: number;
/**
* Approved customers with an open profile change request. Counted separately
* because they are `active` and so are invisible to the `pending` KPI, even
* though they are just as much waiting on a reviewer.
*/
pendingChanges!: number;
}

View File

@@ -48,6 +48,17 @@ export class ListCompaniesQueryDto {
@IsBoolean()
onboardingCompleted?: boolean;
@ApiPropertyOptional({
description:
"`true` = only companies with an open (pending) profile change request. " +
"These are already-approved customers, so they never appear under " +
"`status=pending` and would otherwise be invisible in the review queue.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
hasPendingChangeRequest?: boolean;
@ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"],
default: "name",

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MaxLength, MinLength } from "class-validator";
export class RequestDocumentChangeDto {
/** What is wrong with this document — shown verbatim to the customer. */
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(2000)
note!: string;
}

View File

@@ -37,8 +37,20 @@ export interface BusinessLicenseFile {
*/
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
/**
* Reviewer verdict on a document, as surfaced to clients. Distinct from
* {@link StagedFileStatus}: that describes where the file sits in the staged
* add/remove workflow, this describes whether a reviewer wants it corrected.
*/
export interface FileReviewView {
/** `change_requested` while the customer still owes a corrected upload. */
reviewStatus?: "change_requested" | "approved" | null;
/** The reviewer's reason, shown verbatim to the customer. */
reviewNote?: string | null;
}
/** A business-license file plus its change-review state, surfaced to clients. */
export interface ProfileLicenseFileView {
export interface ProfileLicenseFileView extends FileReviewView {
id: string;
name: string;
size: number;
@@ -47,7 +59,7 @@ export interface ProfileLicenseFileView {
}
/** A company-level document (e.g. the PoA letter) with its change-review state. */
export interface CompanyDocumentFileView {
export interface CompanyDocumentFileView extends FileReviewView {
id: string;
name: string;
size: number;

View File

@@ -198,11 +198,12 @@ export class ContractBookingService {
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
@@ -300,48 +301,57 @@ export class ContractBookingService {
} as never),
);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
await this.persistContainers(booking.id, contract, dto);
}
// Reload with containers to compute the total from contract unit rates × qty.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
// Everything between the insert and the priced update must be all-or-nothing:
// a throw part-way (container persist, weight rules, pricing) would otherwise
// leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
// occupies the one-time contract's single active-booking slot until the
// doc-review sweep expires it — and the clearance cycle still points at the
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
try {
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
await this.persistContainers(booking.id, contract, dto);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// Reject a zero-price booking outright. A total of 0 means no contract rate
// matched the route/container (or the rate is unset), so the booking is not
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
// does NOT occupy the one-time contract's single active-booking slot — else
// the customer's retry hits "already has an active booking" against a broken
// draft. The customer must fix the contract's rates, then rebook.
if (!(computed.totalAmount > 0)) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.hardDelete(booking.id);
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
// Reload with containers to compute the total from contract unit rates × qty.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// Reject a zero-price booking outright. A total of 0 means no contract rate
// matched the route/container (or the rate is unset), so the booking is not
// valid to ship or invoice. The catch below rolls back the row + its lines.
if (!(computed.totalAmount > 0)) {
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
} catch (err) {
await this.bookingsRepository
.deleteContainers(booking.id)
.catch(() => undefined);
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
throw err;
}
// Wagon consolidation gate. A container drawdown whose lines leave a partial
@@ -1599,17 +1609,23 @@ export class ContractBookingService {
throw new BadRequestException('At least one container line is required.');
}
const allowedSizes = new Set(
// Size strings arrive in mixed formats ("20ft" from the contract scope,
// bare "20" from the rebook seed) — compare numerically so format never
// fails a size that IS in scope.
const allowedSizesFt = new Set(
(contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s),
.map((c) => parseInt(c.containerSize ?? '', 10))
.filter((n) => Number.isFinite(n)),
);
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) {
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
if (
allowedSizesFt.size &&
!allowedSizesFt.has(parseInt(line.containerSize, 10))
) {
throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`,
);
@@ -1746,6 +1762,24 @@ export class ContractBookingService {
}),
);
// Same size-scope gate persistContainers enforces at create, surfaced as a
// blocking preview error so the form can't confirm a size the contract does
// not cover. Numeric compare — "20" and "20ft" are the same size.
const allowedSizesFt = new Set(
(contract.cargoScope ?? [])
.map((c) => parseInt(c.containerSize ?? '', 10))
.filter((n) => Number.isFinite(n)),
);
const scopeErrors = allowedSizesFt.size
? [
...new Set(
lines
.map((l) => l.containerSize)
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
),
].map((s) => `Container size ${s} is outside the contract scope.`)
: [];
// The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines.
@@ -1858,7 +1892,7 @@ export class ContractBookingService {
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
capacityErrors: [...scopeErrors, ...capacityErrors],
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,

View File

@@ -1,4 +1,5 @@
import { Contract } from './entities/contract.entity';
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
/**
* Resolves which seeded clearance FileUploadSetting applies to a contract during
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
* gate and collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);

View File

@@ -143,6 +143,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their
* contract simply stays "under approval".
*/
sentBackToStep(c: Contract, targetRole: string, reason: string): void {
this.inAppStaff(
c,
'Contract returned in approval chain',
`Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`,
);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =

View File

@@ -41,6 +41,7 @@ import {
ContractDocumentSnapshotInput,
} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/** The editable contract-document draft returned for the accept/edit dialog. */
@@ -576,17 +577,24 @@ export class ContractTransitionService {
/**
* Reject one approval step (line staff / director / CEO). The rejecting
* approver must supply a reason. A rejection is terminal: the whole contract
* moves to REJECTED and the customer must create a new one — there is no
* resubmit of the same contract. The reason is recorded both on the step and
* as a REJECTION review note so it is visible to the customer and the rest of
* the approval chain.
* approver must supply a reason, and picks where the rejection lands:
*
* - **To the customer** (`returnToStepId` omitted — the only option for the
* first approver): terminal. The whole contract moves to REJECTED with a
* REJECTION review note visible to the customer, who must resubmit.
* - **To an earlier approver** (`returnToStepId` = an already-APPROVED
* earlier step): internal send-back. That step and everything after it
* reset to PENDING and the chain re-runs from there; the contract stays
* PENDING_APPROVAL and the customer never sees it. E.g. the director can
* return a contract to line staff, who fix it and approve again, after
* which every later stage re-approves in order.
*/
async rejectStep(
contractId: string,
stepId: string,
actorId: string,
reason: string,
returnToStepId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
@@ -594,6 +602,20 @@ export class ContractTransitionService {
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step) throw new BadRequestException('Approval step not found');
// Only the approver whose turn it is may reject — same ordering rule as
// approveStep. Without this, an already-actioned or future step could be
// "rejected" and wipe chain state it never owned.
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Only the current pending approval step can be rejected',
);
}
if (returnToStepId) {
return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
await this.contractsRepository.createReviewNote(
@@ -616,6 +638,67 @@ export class ContractTransitionService {
return updated;
}
/**
* Internal send-back branch of rejectStep: return the contract to an earlier,
* already-approved stage of the chain instead of rejecting it outright.
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
* is still alive) and no customer-facing REJECTION note — the trail is a
* staff note plus a backoffice inbox ping.
*/
private async sendBackToStep(
contract: Contract,
rejectingStep: ContractApprovalStep,
actorId: string,
reason: string,
returnToStepId: string,
): Promise<Contract> {
const target = await this.contractsRepository.findApprovalStepById(
contract.id,
returnToStepId,
);
if (!target) throw new BadRequestException('Return-to approval step not found');
if (target.stepOrder >= rejectingStep.stepOrder) {
throw new BadRequestException(
'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
);
}
if (target.status !== 'APPROVED') {
throw new BadRequestException(
`Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
);
}
// Staff-visible trail. Written before the reset so the reason survives the
// wipe of per-step notes.
await this.contractsRepository.createReviewNote(
contract.id,
`Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
'STAFF_NOTE',
actorId,
'STAFF',
);
// Chain re-runs from the target stage: it and every later step (including
// the rejecting one) go back to PENDING. Legacy approved-by columns are
// left stale on purpose — approval steps are the source of truth and the
// columns get re-stamped on re-approval.
await this.contractsRepository.resetApprovalStepsFrom(
contract.id,
target.stepOrder,
);
// A send-back can only happen mid-chain, so the contract must remain (or
// return to) PENDING_APPROVAL — relevant when rejecting from
// APPROVED_PENDING_SIGNATURE.
await this.contractsRepository.update(contract.id, {
status: 'PENDING_APPROVAL',
} as never);
const updated = await this.contractsService.findById(contract.id);
this.notifier.sentBackToStep(updated, target.requiredRole, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,
@@ -1035,8 +1118,8 @@ export class ContractTransitionService {
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,

View File

@@ -442,7 +442,10 @@ export class ContractsController {
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
@ApiOperation({
summary:
'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)',
})
rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@@ -454,6 +457,7 @@ export class ContractsController {
stepId,
resolveAuthUserId(user),
dto.reason,
dto.returnToStepId,
);
}

View File

@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
await this.attachClearancePhases(items);
await this.attachRejectionNotes(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
}
/**
* Attach the latest REJECTION review-note body to each REJECTED contract so
* list consumers (portal rows, backoffice queues) can show why without a
* per-contract detail fetch. One query per page, like `attachContractFiles`.
*/
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
const rejected = contracts.filter((c) => c.status === 'REJECTED');
if (rejected.length === 0) return;
const ids = rejected.map((c) => c.id);
const rows: Array<{ contract_id: string; body: string }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, body
FROM freight.contract_review_notes
WHERE contract_id = ANY($1)
AND note_type = 'REJECTION'
AND deleted_at IS NULL
ORDER BY contract_id, created_at DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
for (const contract of rejected) {
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')
@@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
});
}
/**
* Send-back reset: every step at or after `fromStepOrder` returns to PENDING
* with its actor/verdict cleared, so the chain re-runs from that stage. The
* send-back reason lives in the review-note trail, not on the wiped steps.
*/
async resetApprovalStepsFrom(
contractId: string,
fromStepOrder: number,
): Promise<void> {
await this.dataSource
.getRepository(ContractApprovalStep)
.createQueryBuilder()
.update()
.set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null })
.where('contract_id = :contractId', { contractId })
.andWhere('step_order >= :fromStepOrder', { fromStepOrder })
.execute();
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({

View File

@@ -642,6 +642,47 @@ export class ContractsService {
}
}
// Surface the rejection reason. The approval-step note is wiped on
// send-back resets, so the review-note trail is the only durable source.
if (contract.status === 'REJECTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'REJECTION',
);
contract.latestRejectionNote = note?.body ?? null;
} catch {
contract.latestRejectionNote = null;
}
}
// Surface the send-back reason to the returned-to approver, but only while
// it is still actionable: once any step acts after the send-back the note
// is stale and stays out of the response (the trail keeps it in the DB).
if (contract.status === 'PENDING_APPROVAL') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'STAFF_NOTE',
);
// Stale when any step acted after it (send-back resolved) or when the
// chain itself is newer than the note (fresh cycle after a resubmit).
const staleAfter = Math.max(
0,
...(contract.approvalSteps ?? []).flatMap((s) => [
s.actedAt ? new Date(s.actedAt).getTime() : 0,
s.createdAt ? new Date(s.createdAt).getTime() : 0,
]),
);
contract.latestSendBackNote =
note && new Date(note.createdAt).getTime() > staleAfter
? note.body
: null;
} catch {
contract.latestSendBackNote = null;
}
}
return contract;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
export class ApproveStepDto {
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@@ -26,6 +26,22 @@ export class RejectStepDto {
@IsString()
@MinLength(1)
reason!: string;
/**
* Where the rejection lands. Omitted → the customer: the contract goes to
* REJECTED and the customer must resubmit (unchanged legacy behaviour, and
* the only option for the first approver in the chain). Set to an EARLIER
* approved step's id → send-back: that step and everything after it reset to
* PENDING and the chain re-runs from there; the contract never leaves
* PENDING_APPROVAL and the customer is not involved.
*/
@ApiPropertyOptional({
description:
'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.',
})
@IsOptional()
@IsUUID()
returnToStepId?: string;
}
export class CancelContractDto {

View File

@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
/**
* Body of the most recent REJECTION review note, attached by
* ContractsService.findById when status is REJECTED so both backoffice and
* portal can show why. Lives in contract_review_notes, not a column here.
*/
latestRejectionNote?: string | null;
/**
* Body of the most recent send-back STAFF_NOTE, attached by
* ContractsService.findById while the contract is PENDING_APPROVAL and no
* approval step has acted since the send-back. Lives in
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
}

View File

@@ -1,6 +1,16 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* Reviewer verdict on a single stored document.
*
* `null` (the default) means "not reviewed" — the state every file starts in and
* the only state the customer is not blocked by. `change_requested` is raised by
* a backoffice reviewer against one specific document and is what the customer
* must clear by re-uploading; `approved` records an explicit sign-off.
*/
export type FileReviewStatus = "change_requested" | "approved";
@Entity({ schema: "freight", name: "files" })
export class FileRecord extends BaseEntity {
@Column({ name: "resource_id", type: "uuid" })
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
@Column({ name: "mime_type", type: "varchar", length: 255 })
mimeType!: string;
/** Reviewer verdict, or `null` while the document has never been reviewed. */
@Column({
name: "review_status",
type: "varchar",
length: 32,
nullable: true,
default: null,
})
reviewStatus!: FileReviewStatus | null;
/** Why a change was requested — shown verbatim to the customer. */
@Column({ name: "review_note", type: "text", nullable: true })
reviewNote!: string | null;
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
reviewedBy!: string | null;
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt!: Date | null;
}

View File

@@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of
* resource ids (a company plus each of its company profiles) in one query.
*/
async findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
if (resourceIds.length === 0) return [];
return this.repository.find({
where: {
resourceId: In(resourceIds),
resource,
reviewStatus: "change_requested",
},
order: { createdAt: "ASC" },
});
}
}

View File

@@ -8,7 +8,7 @@ import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord } from "./entities/file.entity";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
export interface CreateFileInput {
resourceId: string;
@@ -169,6 +169,53 @@ export class FilesService {
return record;
}
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
* reason can never outlive the request it explained.
*/
async setReviewStatus(
id: string,
status: FileReviewStatus,
note: string | null,
reviewerId?: string,
): Promise<FileRecord> {
const record = await this.findById(id);
const updated = await this.filesRepository.update(record.id, {
reviewStatus: status,
reviewNote: status === "change_requested" ? (note ?? null) : null,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
});
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Drop any reviewer verdict from a document, returning it to "not reviewed".
* Called when a customer re-uploads: the new bytes have not been looked at, so
* carrying the old `change_requested` forward would keep them blocked forever.
*/
async clearReview(id: string): Promise<void> {
await this.filesRepository.update(id, {
reviewStatus: null,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
}
/** Documents across these resources still awaiting a customer correction. */
findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findWithOpenChangeRequest(
resourceIds,
resource,
);
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);

View File

@@ -333,6 +333,8 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}>
> {
const [lm] = await this.lastMileRepository.findAll({
@@ -347,9 +349,11 @@ export class LastMileService {
? lm.vehicleAssignments.map((va) => ({
vehicle: va.vehicle,
containerNumber: va.containerNumber ?? null,
arrivedAt: va.arrivedAt ?? null,
departedAt: va.departedAt ?? null,
}))
: lm.vehicle
? [{ vehicle: lm.vehicle, containerNumber: null }]
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
: [];
const out: Array<{
@@ -361,8 +365,10 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}> = [];
for (const { vehicle, containerNumber } of sources) {
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
if (!vehicle) continue;
let driverName = vehicle.assignedDriverName ?? null;
let driverLicense: string | null = null;
@@ -386,6 +392,8 @@ export class LastMileService {
driverPhone,
truckType: vehicle.vehicleType || null,
containerNumber,
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
});
}
return out;

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import {
LOCOMOTIVE_STATUSES,
@@ -21,4 +22,29 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
currentYardId?: string;
/**
* Drop locomotives already coupled to a built train — the train-builder
* "change locomotives" picker uses this so a loco that belongs to another
* train is never offered (the backend would 409 on save anyway). Combine with
* `excludeTrainId` to keep the CURRENT train's own locos in the list.
*/
@ApiPropertyOptional({
description: 'Exclude locomotives already coupled to any built train',
})
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
excludeCoupled?: boolean;
/**
* When `excludeCoupled` is set, locos coupled to THIS train are still kept
* (they are valid picks — you are editing that train's consist).
*/
@ApiPropertyOptional({
description: 'Train id whose own coupled locomotives are NOT excluded',
})
@IsOptional()
@IsUUID()
excludeTrainId?: string;
}

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
@@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
super(repository);
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
.orderBy('locomotive.code', 'ASC');
if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status });
if (opts.locomotiveType)
qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType });
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
const sub = this.repository.manager
.getRepository(TrainLocomotive)
.createQueryBuilder('tl')
.select('1')
.where('tl.locomotiveId = locomotive.id');
if (opts.keepTrainId) {
sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId });
}
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
}
/**
* A live locomotive already holding this name, compared the same way the
* `UQ_locomotives_name_active` index compares: case- and whitespace-

View File

@@ -29,6 +29,17 @@ export class LocomotivesService {
}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
// The coupling picker needs a NOT-EXISTS against the train link table, so it
// takes the query-builder path; the plain list keeps the simple where.
if (filter.excludeCoupled) {
return this.locomotivesRepository.findForCoupling({
status: filter.status as LocomotiveStatus | undefined,
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
currentYardId: filter.currentYardId,
excludeCoupled: true,
keepTrainId: filter.excludeTrainId,
});
}
return this.locomotivesRepository.findAll({
where: {
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),

View File

@@ -4,25 +4,22 @@ import {
ArrayMinSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
/**
* Segment distances are no longer part of the payload — they are resolved
* from the configured yard_distances table (Configuration → Yard Distances)
* and snapshotted onto route_milestones at create/update.
*/
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
@IsOptional()
@IsNumber()
@Min(0)
distanceKm?: number;
}
export class CreateRouteDto {

View File

@@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
@Injectable()
export class RoutesService {
constructor(
@@ -183,47 +187,63 @@ export class RoutesService {
return this.findById(id);
}
private async validateMilestones(
milestones: Array<{ yardId: string; distanceKm?: number }>,
) {
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => {
const distanceKm =
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
throw new BadRequestException(
`Enter segment KM for stop ${index + 1} (from previous yard).`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm,
};
});
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
for (const milestone of milestones) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const distanceByPair = await this.loadDistanceLookup(uniqueYardIds);
// Segment km come from the configured yard-distance table, not the payload
// — a route can only be built over pairs an admin has entered. Distances
// are symmetric, so an A→B row also serves B→A.
const missingPairs: string[] = [];
const normalized = milestones.map((milestone, index) => {
if (index === 0) {
return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 };
}
const previousYardId = milestones[index - 1].yardId;
const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId));
if (distanceKm == null) {
const from = yardById.get(previousYardId);
const to = yardById.get(milestone.yardId);
missingPairs.push(
`${from?.label ?? previousYardId}${to?.label ?? milestone.yardId}`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm: distanceKm ?? null,
};
});
if (missingPairs.length > 0) {
throw new BadRequestException(
`No distance configured for: ${missingPairs.join(', ')}. ` +
'Add the missing yard distances in Configuration → Yard Distances first.',
);
}
const originYardId = milestones[0].yardId;
const destinationYardId = milestones[milestones.length - 1].yardId;
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
@@ -236,4 +256,17 @@ export class RoutesService {
milestones: normalized,
};
}
/** Order-insensitive pair → km map over every configured distance touching the yards. */
private async loadDistanceLookup(yardIds: string[]): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(YardDistance)
.find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });
const lookup = new Map<string, number>();
for (const row of rows) {
lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return lookup;
}
}

View File

@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistancesService } from '../services/yard-distances.service';
@ApiTags('yard-distances')
@Controller('yard-distances')
@ApiBearerAuth()
export class YardDistancesController {
constructor(private readonly service: YardDistancesService) {}
@Get()
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'List yard distances' })
findAll(@Query() query: ListYardDistancesQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'Get a yard distance by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Create a yard distance' })
create(@Body() dto: CreateYardDistanceDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Update a yard distance' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yard-distances')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard distance' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, IsUUID, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
export class CreateYardDistanceDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
fromYardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
toYardId!: string;
@ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 })
@Transform(toNumber)
@IsNumber()
@Min(0.01)
distanceKm!: number;
}

View File

@@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto {
sortBy?: string;
}
export class ListYardDistancesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Return only distances touching this yard.' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'distanceKm'])
sortBy?: string;
}
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
@IsOptional()

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateYardDistanceDto } from './create-yard-distance.dto';
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* Configured rail distance between two yards. Route creation reads segment
* kilometres from here (symmetric: A→B serves B→A too) instead of taking
* them as free-text input — see RoutesService.validateMilestones.
*
* Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a
* soft-deleted pair can be re-created.
*/
@Entity({ schema: 'freight', name: 'yard_distances' })
@Index(['fromYardId'])
@Index(['toYardId'])
export class YardDistance extends BaseEntity {
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'from_yard_id' })
fromYard?: Yard;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'to_yard_id' })
toYard?: Yard;
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 })
distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm
}

View File

@@ -0,0 +1,17 @@
import { PaginatedResponse } from '@edr/types';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
export interface IYardDistancesRepository {
findById(id: string): Promise<YardDistance | null>;
/** Exact or reverse pair — distances are symmetric (A→B serves B→A). */
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null>;
/** All rows touching any of the given yards, for batch segment lookups. */
findTouchingYards(yardIds: string[]): Promise<YardDistance[]>;
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>>;
create(data: Partial<YardDistance>): Promise<YardDistance>;
update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null>;
softDelete(id: string): Promise<void>;
}
export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY');

View File

@@ -0,0 +1,87 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { Brackets, DataSource, In, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface';
@Injectable()
export class YardDistancesRepository implements IYardDistancesRepository {
private readonly repo: Repository<YardDistance>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(YardDistance);
}
findById(id: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: { id },
relations: { fromYard: true, toYard: true },
});
}
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: [
{ fromYardId, toYardId },
{ fromYardId: toYardId, toYardId: fromYardId },
],
});
}
findTouchingYards(yardIds: string[]): Promise<YardDistance[]> {
if (!yardIds.length) return Promise.resolve([]);
return this.repo.find({
where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }],
});
}
/** Paged list with server-side search on either yard's label/code. */
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>> {
const qb = this.repo
.createQueryBuilder('yardDistance')
.leftJoinAndSelect('yardDistance.fromYard', 'fromYard')
.leftJoinAndSelect('yardDistance.toYard', 'toYard')
.orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC')
.addOrderBy('fromYard.label', 'ASC');
if (query.yardId) {
qb.andWhere(
new Brackets((w) =>
w
.where('yardDistance.fromYardId = :yardId', { yardId: query.yardId })
.orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }),
),
);
}
if (query.search) {
qb.andWhere(
new Brackets((w) =>
w
.where('fromYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }),
),
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<YardDistance>): Promise<YardDistance> {
const entity = this.repo.create(data);
const saved = await this.repo.save(entity);
return (await this.findById(saved.id)) ?? saved;
}
async update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardDistancesController } from './controllers/yard-distances.controller';
import { YardsController } from './controllers/yards.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
@@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
@@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface';
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
@@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { YardDistancesRepository } from './repositories/yard-distances.repository';
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
@@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardDistancesService } from './services/yard-distances.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
@@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceType,
WeightLimitRule,
Yard,
YardDistance,
YardFacility,
ShippingLine,
Rate,
@@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesController,
WeightLimitRulesController,
YardsController,
YardDistancesController,
ShippingLinesController,
RatesController,
ApprovalRulesController,
@@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
YardsRepository,
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
YardDistancesRepository,
{ provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository },
ShippingLinesRepository,
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
RatesRepository,
@@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesService,
WeightLimitRulesService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
WeightLimitRulesService,
PriorityConfigsService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
SERVICE_TYPES_REPOSITORY,
SHIPPING_LINES_REPOSITORY,
YARDS_REPOSITORY,
YARD_DISTANCES_REPOSITORY,
],
})
export class RuleEngineModule {}

View File

@@ -0,0 +1,119 @@
import { PaginatedResponse } from '@edr/types';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import {
IYardDistancesRepository,
YARD_DISTANCES_REPOSITORY,
} from '../interfaces/yard-distances.repository.interface';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/**
* Flat row shape for the backoffice config table: the yard relations stay for
* API consumers, plus label fields the generic rule-engine grid can render.
*/
export type YardDistanceRow = YardDistance & {
fromYardLabel: string;
toYardLabel: string;
};
const yardDisplay = (yard?: { label?: string; code?: string } | null): string =>
yard?.label ?? yard?.code ?? '—';
const toRow = (entity: YardDistance): YardDistanceRow =>
Object.assign(entity, {
fromYardLabel: yardDisplay(entity.fromYard),
toYardLabel: yardDisplay(entity.toYard),
});
@Injectable()
export class YardDistancesService {
constructor(
@Inject(YARD_DISTANCES_REPOSITORY)
private readonly repository: IYardDistancesRepository,
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
) {}
async findAll(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistanceRow>> {
const page = await this.repository.findPaged(query);
return { ...page, items: page.items.map(toRow) };
}
async findById(id: string): Promise<YardDistanceRow> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(entity);
}
async create(dto: CreateYardDistanceDto): Promise<YardDistanceRow> {
await this.assertValidPair(dto.fromYardId, dto.toYardId);
const created = await this.repository.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
distanceKm: dto.distanceKm.toFixed(2),
});
return toRow(created);
}
async update(id: string, dto: UpdateYardDistanceDto): Promise<YardDistanceRow> {
const existing = await this.findById(id);
const fromYardId = dto.fromYardId ?? existing.fromYardId;
const toYardId = dto.toYardId ?? existing.toYardId;
if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) {
await this.assertValidPair(fromYardId, toYardId, id);
}
const updated = await this.repository.update(id, {
fromYardId,
toYardId,
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}),
});
if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
/**
* Both yards must exist and differ, and the pair must not already be
* configured in either direction — distances are symmetric, so an A→B row
* already covers B→A.
*/
private async assertValidPair(
fromYardId: string,
toYardId: string,
ignoreId?: string,
): Promise<void> {
if (fromYardId === toYardId) {
throw new BadRequestException('From and to yards must be different');
}
const [fromYard, toYard] = await Promise.all([
this.yardsRepository.findById(fromYardId),
this.yardsRepository.findById(toYardId),
]);
if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`);
if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`);
const existing = await this.repository.findBetween(fromYardId, toYardId);
if (existing && existing.id !== ignoreId) {
throw new ConflictException(
`A distance between ${fromYard.label} and ${toYard.label} is already configured`,
);
}
}
}

View File

@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -68,6 +69,9 @@ export class BookingJourneyService {
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
const now = new Date();
await this.dataSource.transaction(async (manager) => {

View File

@@ -2632,6 +2632,11 @@ export class TrainSchedulingService {
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
await this.assertExportBookingsReceived([...wagonAssignedIds]);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
@@ -2909,6 +2914,38 @@ export class TrainSchedulingService {
return direction === 'EXPORT';
}
/**
* Every export booking being confirmed loaded must already be received at the
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
* is the check that the cargo is physically in the yard before we call it loaded.
*/
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
if (!bookingIds.length) return;
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT b.reference
FROM freight.bookings b
WHERE b.id = ANY($1)
AND b.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
)`,
[bookingIds],
);
if (rows.length) {
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
throw new BadRequestException(
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
);
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')

View File

@@ -29,6 +29,9 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/** Locomotive statuses that block a train from reactivating. */
const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']);
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
@@ -596,11 +599,30 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
/**
* Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled
* again. Blocked if any coupled locomotive is unfit for service — a
* deactivated train can sit parked for a while and its locomotives may have
* since been sent to maintenance independently; reactivating must not wave
* a down locomotive back onto the schedule board.
*/
async activate(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) {
const links = await this.dataSource
.getRepository(TrainLocomotive)
.find({ where: { trainId: id }, relations: { locomotive: true } });
const unfit = links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco))
.filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status));
if (unfit.length) {
const names = unfit.map((l) => `${l.code} (${l.status})`).join(', ');
throw new ConflictException(
`Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`,
);
}
await this.dataSource
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Available });

View File

@@ -0,0 +1,61 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateVehicleDto } from './create-vehicle.dto';
const base = {
vehicleType: 'TRUCK',
manufacturer: 'IVECO',
model: 'HYT',
year: 2020,
fuelType: 'DIESEL',
capacity: 0,
status: 'ACTIVE',
};
const errorsFor = (over: Record<string, unknown>) =>
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
const plateErrors = (
errors: Awaited<ReturnType<typeof errorsFor>>,
property: string,
) => errors.find((e) => e.property === property && e.constraints?.matches);
describe('CreateVehicleDto — plate format', () => {
it('accepts a plate like ET-9875', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined();
});
it('accepts a plate like AA-8642', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined();
});
it('upper-cases a lower-case plate before validating', async () => {
const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' });
expect(dto.plateNumber).toBe('ET-9875');
expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined();
});
it('rejects a free-text plate like assadasd', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined();
});
it('rejects a plate with no letters or no digits', async () => {
expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined();
expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined();
});
it('rejects a bad trailer plate but allows a valid one', async () => {
expect(
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'),
).toBeDefined();
expect(
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'),
).toBeUndefined();
});
it('allows an empty trailer plate (optional)', async () => {
const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' });
expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined();
});
});

View File

@@ -1,7 +1,30 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
/**
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
* e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer
* all match and the message stays consistent.
*/
export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/;
export const VEHICLE_PLATE_MESSAGE =
'must be letters and numbers like ET-9875 or AA-8642';
/**
* Trim and upper-case a plate before validating, so "et-9875" is accepted. An
* empty optional plate (trailer/power) becomes undefined so @IsOptional skips it
* rather than failing the pattern.
*/
const normalizePlate = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim().toUpperCase();
return trimmed === '' ? undefined : trimmed;
};
export class CreateVehicleDto {
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
plateNumber!: string;
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
code?: string;
@IsOptional()
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
powerPlateNo?: string;
@IsOptional()
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
trailerPlateNo?: string;

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import {
WAREHOUSE_INVENTORY_STATUSES,
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
dateTo?: string;
// ── KPI drill-down filters ──────────────────────────────────────────────
// Each mirrors one opsStats() counter so a dashboard card's count always
// equals the length of the list it opens.
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
receivedToday?: boolean;
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
pendingInspection?: boolean;
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsInt()
@Min(1)
agingOverDays?: number;
}

View File

@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
* and signed before the truck leaves; EDR last-mile handovers are generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal on delivery — one signature per truck.
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
edrAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;

View File

@@ -0,0 +1,66 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
const svc = Object.create(WarehouseInventoryService.prototype) as any;
const arrivalA =
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
const arrivalB =
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
describe('per-truck exit inspection blocks', () => {
it('keeps truck A intact when truck B arrives', () => {
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
expect(afterB).toContain('Abebe Lemeno');
expect(afterB).toContain('Suleman Tamrat');
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
expect(afterB.startsWith('Receive note')).toBe(true);
});
it("exit for truck A updates only A's block and preserves arrival data", () => {
const notes = svc.replaceExitInspectionNote(
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
arrivalB,
'3-85957/48562',
);
const dto = svc.preserveTruckArrivalForExit(
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
notes,
);
expect(dto.driverName).toBe('Abebe Lemeno');
expect(dto.tareWeight).toBe(12);
expect(dto.weighingSkipped).toBeUndefined();
const exitNote = svc.buildExitInspectionNote(dto);
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
expect(replaced).toContain('Gross Weight: 40 t');
expect(replaced).toContain('Net Weight: 28 t');
expect(replaced).toContain('Suleman Tamrat'); // B untouched
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
});
it('skipped weighing records the container-derived net in the note', () => {
const dto = {
truckPlateNumber: '3-85957/48562',
driverName: 'Suleman Tamrat',
weighingSkipped: true,
netWeight: 27.5,
gateInTime: '2026-07-21T09:00:00.000Z',
gateOutTime: '2026-07-21T13:00:00.000Z',
};
const note = svc.buildExitInspectionNote(dto);
expect(note).toContain('Weighing: SKIPPED');
expect(note).toContain('Net Weight: 27.5 t');
});
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
const legacy =
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
expect(block).toContain('Abebe Lemeno');
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
expect(replaced).toContain('Receive note');
});
});

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
* exit paper), signed by the customer in the portal per truck; once every
* handover is signed the delivery auto-completes (inventory / cargo /
* booking → delivered).
*/
@Injectable()
export class HandoverService {
@@ -25,14 +28,22 @@ export class HandoverService {
) {}
/** Tell the customer a handover is ready and needs their signature. */
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
private async notifySignNeeded(
bookingId: string,
reference: string,
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
const body =
opts.mileType === 'EDR_LAST_MILE'
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
@@ -117,31 +128,98 @@ export class HandoverService {
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
private async findEdrHandover(
repo: Repository<BookingHandover>,
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
): Promise<BookingHandover | null> {
if (opts.edrAssignmentId) {
const byAssignment = await repo.findOne({
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
});
if (byAssignment) return byAssignment;
}
if (opts.truckPlate) {
return repo.findOne({
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
});
}
return repo.findOne({
where: {
bookingId,
mileType: 'EDR_LAST_MILE',
truckPlate: IsNull(),
edrAssignmentId: IsNull(),
},
});
}
/**
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
* (alongside its exit paper) and ask the customer to sign it from the portal.
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
*/
async ensureForDepartedEdrTruck(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/**
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
* Normally the handover was already generated on truck exit — this only fills
* the delivery timestamp; a handover is created here only for legacy flows
* where the exit was recorded before this feature existed.
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) {
if (!existing.deliveredAt) {
existing.deliveredAt = new Date();
await repo.save(existing);
}
return existing;
}
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
@@ -149,6 +227,25 @@ export class HandoverService {
deliveredAt: new Date(),
}),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/** Re-send the sign notification for every unsigned handover on the booking. */
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
where: { bookingId, signedAt: IsNull() },
order: { generatedAt: 'ASC' },
});
for (const h of unsigned) {
await this.notifySignNeeded(bookingId, h.reference, {
mileType: h.mileType,
truckPlate: h.truckPlate,
});
}
}
/**
@@ -182,6 +279,27 @@ export class HandoverService {
}
}
/**
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
* fresh handover; idempotent — an already-signed handover is returned as-is.
*/
async sign(
handoverId: string,
userId?: string | null,
signerName?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
if (!handover) {
throw new NotFoundException(`Handover ${handoverId} not found`);
}
if (handover.signedAt) return handover;
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
return repo.save(handover);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(
bookingId: string,

View File

@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Export cargo is received into the warehouse to wait for its train, and only a
* paid booking may be received — otherwise storage and a GRN would start against
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
* train and its receive is the unload.
*
* The guard touches only the DataSource, so the instance is built off the
* prototype rather than stubbing all 20-odd collaborators.
*/
type Guard = (
bookingId: string | null | undefined,
direction: string | null,
) => Promise<void>;
function makeGuard(paymentStatus: string | null) {
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.dataSource = { query };
const guard = (
service as unknown as { assertExportBookingPaid: Guard }
).assertExportBookingPaid.bind(service);
return { guard, query };
}
describe('receive() — export paid gate', () => {
it('rejects an unpaid export booking', async () => {
const { guard } = makeGuard('PENDING');
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
});
it('allows a paid export booking', async () => {
const { guard } = makeGuard('PAID');
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
});
it('never blocks import, paid or not', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
it('ignores a receive with no booking attached', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,86 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
import type { UnloadBookingDto } from './dto/unload-booking.dto';
/**
* A GRN is the receipt for cargo entering the warehouse, so unloadBooking must
* issue one for every direction — import as well as export. It used to mint only
* for export, leaving import cargo received with no GRN.
*/
function makeService(opts: {
tradeDirection: string | null;
existing?: { id: string; grnNumber: string | null };
}) {
const created: Record<string, unknown>[] = [];
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
const inventoryRepository = {
findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []),
update: jest.fn((id: string, patch: Record<string, unknown>) => {
updated.push({ id, patch });
return Promise.resolve();
}),
create: jest.fn((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve({ id: 'new-inv', ...row });
}),
};
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.inventoryRepository = inventoryRepository;
service.dataSource = {
query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]),
};
// Location comes straight from the dto in these cases, so pickDefaultLocation
// is never reached; findById just echoes what was written.
service.findById = jest.fn((id: string) =>
Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }),
);
const dto: UnloadBookingDto = {
warehouseId: 'w1',
yardId: 'y1',
zoneId: 'z1',
} as UnloadBookingDto;
return { service: service as unknown as WarehouseInventoryService, dto, created, updated };
}
describe('unloadBooking — GRN issuance', () => {
it('issues an IMPORT GRN when unloading a fresh import booking', async () => {
const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' });
await service.unloadBooking('b-import', dto);
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('still issues an EXPORT GRN', async () => {
const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' });
await service.unloadBooking('b-export', dto);
expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/);
});
it('mints a GRN for an existing import row that has none', async () => {
const { service, dto, updated } = makeService({
tradeDirection: 'IMPORT',
existing: { id: 'inv-1', grnNumber: null },
});
await service.unloadBooking('b-import', dto);
expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('does not reissue when the row already has a GRN', async () => {
const { service, dto, updated } = makeService({
tradeDirection: 'IMPORT',
existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' },
});
await service.unloadBooking('b-import', dto);
expect(updated[0].patch).not.toHaveProperty('grnNumber');
});
});

View File

@@ -1,6 +1,18 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
Between,
DataSource,
EntityManager,
FindManyOptions,
FindOptionsWhere,
ILike,
In,
IsNull,
LessThanOrEqual,
MoreThanOrEqual,
Raw,
} from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
@@ -68,6 +80,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
export interface InventoryInquiryResult {
id: string;
@@ -417,12 +430,16 @@ export class WarehouseInventoryService {
*
* Covers both haulage paths because the gate does: a customer's own truck and
* an EDR last-mile truck arrive at the same barrier and need the same paper.
* "On site" means arrived and not yet departed.
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
* what is coming as well as what is here — an assigned truck only stamps
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
* it departs.
*/
async trucksOnSite(): Promise<
Array<{
source: 'CUSTOMER' | 'EDR';
assignmentId: string;
status: 'INBOUND' | 'ON_SITE';
plateNumber: string | null;
driverName: string | null;
truckType: string | null;
@@ -436,6 +453,7 @@ export class WarehouseInventoryService {
return this.dataSource.query(
`SELECT 'CUSTOMER' AS "source",
a.id AS "assignmentId",
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
@@ -450,13 +468,13 @@ export class WarehouseInventoryService {
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.deleted_at IS NULL
AND a.arrived_at IS NOT NULL
AND a.departed_at IS NULL
UNION ALL
SELECT 'EDR' AS "source",
va.id AS "assignmentId",
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
v.vehicle_type AS "truckType",
@@ -474,10 +492,11 @@ export class WarehouseInventoryService {
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE va.deleted_at IS NULL
AND va.arrived_at IS NOT NULL
AND va.departed_at IS NULL
ORDER BY "arrivedAt" ASC`,
-- On-site trucks first, each group oldest-arrival first; inbound trucks
-- (null arrival) sort to the end.
ORDER BY "arrivedAt" ASC NULLS LAST`,
);
}
@@ -502,12 +521,20 @@ export class WarehouseInventoryService {
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
((SELECT count(*)::int FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
+
(SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
AND status = ANY($1)
AND created_at < now() - interval '7 days') AS "itemsAging"`,
[this.IN_WAREHOUSE_STATUSES],
);
return {
receivedToday: row?.receivedToday ?? 0,
@@ -519,7 +546,7 @@ export class WarehouseInventoryService {
}
/** In-warehouse statuses used by the dwell / aging metrics. */
private readonly IN_WAREHOUSE_STATUSES = [
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
'RECEIVED',
'UNLOADED',
'STORED',
@@ -947,7 +974,7 @@ export class WarehouseInventoryService {
? LessThanOrEqual(new Date(filter.dateTo))
: undefined;
const base = {
const base: FindOptionsWhere<WarehouseInventory> = {
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
...(filter.yardId ? { yardId: filter.yardId } : {}),
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
@@ -961,6 +988,24 @@ export class WarehouseInventoryService {
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
};
// KPI drill-down filters — predicates mirror opsStats() exactly so the
// dashboard card's count equals the length of the list it opens.
if (filter.receivedToday) {
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
}
if (filter.pendingInspection) {
base.status = 'RECEIVED';
base.inspectionStatus = IsNull();
}
if (filter.agingOverDays) {
if (!filter.status && !filter.pendingInspection) {
base.status = In(this.IN_WAREHOUSE_STATUSES);
}
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
days: filter.agingOverDays,
});
}
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? [
@@ -1138,14 +1183,15 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
// A GRN is the receipt for cargo entering the warehouse, so every booking
// gets one on unload — import as well as export. The direction only decides
// the GRN prefix, not whether one is issued.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1166,10 +1212,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber
? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1184,9 +1230,7 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -2551,6 +2595,7 @@ export class WarehouseInventoryService {
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
@@ -2966,12 +3011,29 @@ export class WarehouseInventoryService {
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = isTruckLeaving
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
: dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = isTruckLeaving
? this.preserveTruckArrivalForExit(dto, item.notes)
: dto;
// One reference per item — the first truck's arrival mints it, later trucks
// (arrival or exit) reuse it so all exit papers share the release order.
const reference =
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = {
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
};
// Weighbridge skipped on exit: the recorded net still comes from what the
// truck is holding — the summed cargo weight of its selected containers.
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
const selected = (exitInspectionDto.containerNumber ?? '')
.split(/[,;\n]+/)
.map((n) => n.trim())
.filter(Boolean);
if (selected.length) {
const weights = await this.bookingContainerWeights(item.bookingId);
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
const heldTons = Number(
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
);
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
}
}
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
// The load actually leaving on this truck, in TONNES (the weighing UI is in
@@ -2984,12 +3046,46 @@ export class WarehouseInventoryService {
? Math.round((grossTons - tareTons) * 1000) / 1000
: (exitInspectionDto.netWeight ?? null);
// The weight to record on the inventory when this truck leaves: prefer the
// item's own container cargo weight (a truck may carry other items too);
// fall back to the truck's recorded net. Fills an empty weight only.
let recordedItemTons: number | null = null;
if (isTruckLeaving && netTons != null) {
recordedItemTons = netTons;
if (item.containerId && item.bookingId) {
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
[item.containerId],
);
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
if (ownNumber) {
const weights = await this.bookingContainerWeights(item.bookingId);
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
notes: this.replaceExitInspectionNote(
item.notes,
exitInspectionNote,
exitInspectionDto.truckPlateNumber,
),
});
// Even an unweighed truck records the inventory weight it is holding —
// without this the handover/exit papers print "0 t" for skipped weighings.
if (recordedItemTons != null) {
await manager.query(
`UPDATE freight.warehouse_inventory
SET weight = $2, updated_at = NOW()
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
[id, recordedItemTons],
);
}
if (!isTruckLeaving && item.bookingId) {
// Per-truck arrival: mark the customer truck carrying THIS item's
// container as arrived (matched via the physical container number).
@@ -3855,7 +3951,9 @@ export class WarehouseInventoryService {
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM when no weight was recorded.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
@@ -3907,6 +4005,16 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id
AND bcu.deleted_at IS NULL
AND (container.container_number IS NULL
OR bcu.container_number = container.container_number)
) item_vgm ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -4003,14 +4111,25 @@ export class WarehouseInventoryService {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
const totalTrucks = Number(trucks?.total ?? 0);
const leftTrucks = Number(trucks?.left ?? 0);
if (leftTrucks === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
// Multi-truck booking: every assigned truck must arrive and leave —
// each is weighed out separately before the goods count as delivered.
if (leftTrucks < totalTrucks) {
throw new BadRequestException(
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
);
}
}
}
@@ -5291,7 +5410,11 @@ export class WarehouseInventoryService {
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
// Skipped weighing still records a net — the cargo weight of the
// containers the truck is holding, resolved by the caller.
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
? null
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
@@ -5299,12 +5422,14 @@ export class WarehouseInventoryService {
}
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
const inspection = this.extractExitInspectionNote(notes);
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
if (!inspection) return dto;
return {
...dto,
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
// The submitted plate wins: a legacy block may store a comma-joined list
// of plates, and the exit must be recorded against the ONE truck leaving.
truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
@@ -5318,25 +5443,94 @@ export class WarehouseInventoryService {
};
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
/**
* Split notes into exit-inspection blocks (one per truck, in order) and
* everything else. A block ends at the first line that isn't one of the
* known inspection labels, so appended notes (delivery approval, handover
* marker) are preserved as "other" content instead of being swallowed by
* the block they happen to follow.
*/
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
if (!trimmed) return { others: [], blocks: [] };
const labelPattern =
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
const others: string[] = [];
const blocks: string[] = [];
if (parts[0]?.trim()) others.push(parts[0].trim());
for (const part of parts.slice(1)) {
const lines = part.split('\n');
const kept: string[] = [];
let i = 0;
while (i < lines.length && !lines[i].trim()) i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i].trim();
if (!line || !labelPattern.test(line)) break;
kept.push(line);
}
if (kept.length) blocks.push(kept.join('\n'));
const tail = lines.slice(i).join('\n').trim();
if (tail) others.push(tail);
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
return { others, blocks };
}
/**
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
* legacy comma-joined list ("P1, P2") containing it.
*/
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
const plate = plateNumber?.trim().toUpperCase();
if (!plate) return false;
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
if (!stored) return false;
if (stored === plate) return true;
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
}
/**
* Replace THIS truck's inspection block (matched by plate), keeping every
* other truck's block untouched; append when the plate has no block yet.
* A single legacy block (comma-joined plates or plate-less caller) is
* replaced in place so old single-truck items keep their behaviour.
*/
private replaceExitInspectionNote(
notes: string | null | undefined,
exitInspectionNote: string | null,
plateNumber?: string | null,
): string | null {
const { others, blocks } = this.splitExitInspectionSections(notes);
if (exitInspectionNote) {
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
const index = plateNumber
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
: blocks.length - 1;
if (index >= 0) blocks[index] = content;
else blocks.push(content);
}
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
return sections.join('\n\n') || null;
}
/** Latest truck's inspection block — legacy summary for documents. */
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
const index = notes.lastIndexOf(marker);
if (index < 0) return null;
return notes.slice(index + marker.length).trim() || null;
const { blocks } = this.splitExitInspectionSections(notes);
return blocks.length ? blocks[blocks.length - 1] : null;
}
/**
* The inspection block for one truck. Falls back to a lone existing block so
* legacy single-truck items (saved before per-plate blocks) keep working.
*/
private extractExitInspectionForPlate(
notes: string | null | undefined,
plateNumber?: string | null,
): string | null {
const { blocks } = this.splitExitInspectionSections(notes);
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
if (match) return match;
return blocks.length === 1 ? blocks[0] : null;
}
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
@@ -5649,6 +5843,31 @@ export class WarehouseInventoryService {
);
}
/**
* Export cargo is received into the warehouse to wait for its train, and it is
* received only once the booking is paid — receiving an unpaid export booking
* would start storage and mint a GRN against cargo the customer has not settled.
*
* Export only: import cargo arrives OFF a train and its receive is the unload,
* so gating that on payment would strand cargo already at the yard.
*/
private async assertExportBookingPaid(
bookingId: string | null | undefined,
direction: string | null,
): Promise<void> {
if (!bookingId || direction !== 'EXPORT') return;
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
`SELECT payment_status AS "paymentStatus"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
throw new BadRequestException(
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
);
}
}
private assertCapacity(
label: string,
node: LocationNode,

View File

@@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Intercity documents ─────────────────────────────────────────────────────
// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations.
// ONE_TIME contracts collect it at contract level after both signatures;
// GENERAL contracts collect it per booking right after the booking is created.
// Fields start empty and are configured in the backoffice file-settings editor.
const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "intercity_documents",
label: "Intercity documents",
entity: "booking",
fields: [],
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty

View File

@@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'priority-configs',
'rates',
'approval-rules',
'yard-distances',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
};
/**

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import { captureApiError } from "@/lib/posthog";
@@ -10,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -92,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -108,16 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
// const payload = extractApiErrorPayload(error);
// if (payload) emitApiError(payload);
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -8,6 +8,7 @@ import {
Button,
Box,
Modal,
Select,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
// earlier APPROVED step to send the chain back to. First approver has no
// choice — customer only.
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
const steps = useMemo(
() =>
@@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// The card also renders read-only trails (e.g. a REJECTED contract) — only
// offer approve/reject while the backend accepts step actions.
const actionable =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
@@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectTarget("CUSTOMER");
setRejectOpen(true);
};
@@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
setRejectTarget("CUSTOMER");
};
const trimmedReason = rejectReason.trim();
// Earlier stages this rejection can be returned to — only stages that have
// already approved. Empty for the first approver, whose only target is the
// customer.
const returnableSteps = rejectStepRow
? steps.filter(
(s) =>
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
)
: [];
const sendBack = rejectTarget !== "CUSTOMER";
const targetStep = sendBack
? returnableSteps.find((s) => s.id === rejectTarget)
: undefined;
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{ stepId: rejectStepRow.id, reason: trimmedReason },
{
stepId: rejectStepRow.id,
reason: trimmedReason,
returnToStepId: sendBack ? rejectTarget : undefined,
},
{ onSuccess: () => closeReject() },
);
};
@@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isNext={actionable && nextPending?.id === step.id}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -192,21 +223,56 @@ export function ContractApprovalStepsCard({
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
{returnableSteps.length > 0 && (
<Select
label="Send rejection to"
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
allowDeselect={false}
value={rejectTarget}
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
data={[
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
})),
]}
/>
)}
{sendBack ? (
<Text size="sm" c="dimmed">
Contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
notified.
</Text>
) : (
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must resubmit this cannot be undone.
</Text>
)}
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
description={
sendBack
? "Shared with the approval chain (not the customer)."
: "Shared with the customer and the approval chain."
}
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
@@ -219,14 +285,16 @@ export function ContractApprovalStepsCard({
Cancel
</Button>
<Button
color="red"
color={sendBack ? "orange" : "red"}
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
: "Reject contract"}
</Button>
</Group>
</Stack>

View File

@@ -447,6 +447,17 @@ export default function GlCreateBookingForm() {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
// form entry would send.
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
if (sizeFt == null) return "";
return (
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
`${sizeFt}ft`
);
};
setPrefilled(true);
setContainerLines(
lines.map((c) => {
@@ -466,7 +477,7 @@ export default function GlCreateBookingForm() {
}))
: Array.from({ length: qty }, emptyUnit);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
containerSize: scopeSizeForFt(c.containerType?.sizeFt),
quantity: String(qty),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
@@ -475,7 +486,7 @@ export default function GlCreateBookingForm() {
};
}),
);
}, [copyFromBooking, prefilled]);
}, [copyFromBooking, prefilled, containerSizes]);
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.

View File

@@ -0,0 +1,101 @@
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { FilePen } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { CustomerDocument } from "@/types/customer";
export interface RequestDocumentChangeModalProps {
/** The document under review; `null` closes the modal. */
document: CustomerDocument | null;
companyId: string;
onClose: () => void;
}
/**
* Ask the customer to correct one uploaded document.
*
* Deliberately narrower than rejecting a whole role: the customer keeps every
* other document and only re-uploads this one. The role cannot be approved
* while the request is open, so the note has to say what is actually wrong —
* it is shown to the customer verbatim.
*/
export function RequestDocumentChangeModal({
document,
companyId,
onClose,
}: RequestDocumentChangeModalProps) {
const requestChange = useMutation(
api.customers.requestDocumentChange.mutationOptions(),
);
const [note, setNote] = useState("");
// Re-opening on a document that already has an open request should show what
// was asked for, so the reviewer edits the reason rather than retyping it.
useEffect(() => {
setNote(document?.reviewNote ?? "");
}, [document?.id, document?.reviewNote]);
const submit = () => {
if (!document) return;
requestChange.mutate(
{ companyId, fileId: document.id, note: note.trim() },
{ onSuccess: onClose },
);
};
return (
<Modal
opened={document !== null}
onClose={onClose}
title="Request a change"
centered
radius="lg"
>
<Stack gap="md">
<Alert color="orange" variant="light" icon={<FilePen size={18} />}>
The customer is notified and sees this note verbatim. This role cannot
be approved until they upload a corrected document.
</Alert>
<Text size="sm" c="dimmed">
Document: <strong>{document?.name}</strong>
</Text>
<Textarea
label="What needs correcting?"
placeholder="e.g. The trade license scan is cut off — please re-upload the full page."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={onClose}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button
color="orange"
loading={requestChange.isPending}
disabled={note.trim().length === 0}
onClick={submit}
>
Request change
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -13,6 +13,10 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,

View File

@@ -249,6 +249,16 @@ const FleetFormDialog = ({
}
}
}
// Format check (e.g. plate numbers). Skipped for an empty optional field —
// "required" above already owns the empty case. Upper-cased to match the
// server, which stores plates upper-case.
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
if (!field.pattern.regex.test(candidate)) {
next[field.name] = field.pattern.message;
}
}
});
setErrors(next);
return Object.keys(next).length === 0;

View File

@@ -1,6 +1,7 @@
import { Card, Skeleton, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
@@ -22,6 +23,11 @@ export interface KpiItem {
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
*/
delta?: number;
/**
* Optional route the cell links to — its detail view. When set the cell
* becomes clickable (pointer, hover tint); when absent it stays static.
*/
href?: string;
}
export interface KpiStripProps {
@@ -47,13 +53,22 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
// A cell with an href becomes a link to its detail; without one it
// stays a plain div. Same layout classes either way.
const Cell: ElementType = item.href ? Link : "div";
const linkProps = item.href
? { to: item.href, "aria-label": `${item.label} — view detail` }
: {};
return (
<div
<Cell
key={item.label}
{...(linkProps as Record<string, unknown>)}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)}
>
{Icon ? (
@@ -102,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
</Cell>
);
})}
</div>

View File

@@ -46,10 +46,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
// Only serviceable locomotives standing in the selected yard, and not already
// coupled to another built train, can be picked. A new train owns none yet, so
// no train to keep-exclude.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
},
},
enabled: Boolean(yardId),
}),
);

View File

@@ -26,9 +26,19 @@ export default function ChangeLocomotivesModal({
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
// A locomotive already coupled to ANOTHER built train is not a valid pick —
// the API rejects it on save. Exclude those here (keeping this train's own
// ones, which are re-listed below as "(coupled)").
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
excludeTrainId: composition?.id,
},
},
enabled: opened && Boolean(yardId),
}),
);

View File

@@ -28,6 +28,34 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Statuses that block a deactivated train from reactivating (mirrors the API gate). */
export const UNFIT_LOCOMOTIVE_STATUSES = new Set(["MAINTENANCE", "OUT_OF_SERVICE", "UNAVAILABLE"]);
/** Badge color per locomotive status (Mantine palette keys). */
export const locomotiveStatusColor = (status: string): string => {
switch (status) {
case "AVAILABLE":
case "IMPORT_READY":
case "EXPORT_READY":
return "edr-green";
case "ASSIGNED":
return "blue";
case "MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
case "UNAVAILABLE":
return "red";
default:
return "gray";
}
};
export const locomotiveStatusLabel = (status: string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";

View File

@@ -29,7 +29,10 @@ const parseError = (error: unknown, fallback: string) => {
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
// A capacity axis can be null when the schedule's locomotive has no limit
// configured for it — render "—" instead of crashing on toFixed.
function fmt(n: number | null | undefined): string {
if (n == null) return "—";
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
@@ -43,13 +46,22 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.wagons == null ? "gray" : capacity.wagons > 0 ? "teal" : "red"}
>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.weightTons == null ? "gray" : capacity.weightTons > 0 ? "teal" : "red"}
>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.lengthMeters == null ? "gray" : capacity.lengthMeters > 0 ? "teal" : "red"}
>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>

View File

@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</ActionIcon>
</Tooltip>
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
{/* Stays visible after the first exit — multi-truck bookings
weigh each truck in and out until all have left. */}
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
? 'Truck arrival / leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
interface InspectionBlock {
truckPlateNumber: string;
trailerPlateNumber: string;
driverName: string;
driverLicense: string;
driverPhone: string;
truckType: string;
containerNumber: string;
gateInTime: string;
tareWeight: number | '';
grossWeight: number | '';
netWeight: number | '';
gateOutTime: string;
weighingSkipped: boolean;
}
const parseInspectionSection = (note: string): InspectionBlock => ({
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
});
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
(notes ?? '')
.split(EXIT_INSPECTION_MARKER)
.slice(1)
.map(parseInspectionSection)
.filter((block) => block.truckPlateNumber);
/** Match by plate; a legacy block may hold a comma-joined plate list. */
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
const key = plate.trim().toUpperCase();
if (!key) return undefined;
return blocks.find((block) => {
const stored = block.truckPlateNumber.toUpperCase();
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
});
};
const blockArrived = (block: InspectionBlock | undefined) =>
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
const blockLeft = (block: InspectionBlock | undefined) =>
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
// Some openers (inventory workbench) supply bookingId without the booking
// relation — fall back to it, or the truck/container-weight queries never run.
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false);
// Plate whose saved block was last loaded into the form — stops the
// per-plate loader effect from clobbering operator edits in a loop.
const loadedPlateRef = useRef<string | null>(null);
useEffect(() => {
if (opened) {
const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
// truck query resolves, auto-fill the first assigned EDR truck — without
// overwriting anything the operator typed or the locked exit-step values.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
const first = lastMileTrucks[0];
if (!first) return;
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
setDriverName((p) => p || first.driverName || '');
setDriverLicense((p) => p || first.driverLicense || '');
setDriverPhone((p) => p || first.driverPhone || '');
setTruckType((p) => p || first.truckType || '');
setContainerNumbers((prev) =>
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
);
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
// The same for a customer self-haul truck. The prefill above reads the
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
// and driver to customer_truck_assignments and leaves those columns null — so
// a booking with a truck on file still opened this form blank. Only auto-fills
// a single truck: with several, the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
if (customerTrucks.length !== 1) return;
const [truck] = customerTrucks;
setTruckPlateNumber((p) => p || truck.plateNumber || '');
setDriverName((p) => p || truck.driverName || '');
setTruckType((p) => p || truck.truckType || '');
setContainerNumbers((prev) => {
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
return prev.every((n) => !n) && loaded.length ? loaded : prev;
});
}, [opened, truckPrefill, isExitStep, customerTrucks]);
const savedBlocks = parseInspectionBlocks(item?.notes);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? [
{
value: truckPrefill.truckPlateNumber,
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
arrived: false,
left: false,
},
]
: []),
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
...lastMileTrucks
.filter((t) => t.truckPlateNumber || t.vehicleId)
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName ?? '',
driverPhone: t.driverPhone ?? '',
truckType: t.truckType ?? '',
containerNumbers: splitContainerNumbers(t.containerNumber),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const truckSelectOptions = [
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
];
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
// Per-truck progress: every truck is weighed in and out on its own; the saved
// blocks also cover walk-in trucks that were never formally assigned.
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
for (const option of truckSelectOptions) {
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
}
for (const block of savedBlocks) {
const key = block.truckPlateNumber.trim().toUpperCase();
const prior = truckProgress.get(key);
truckProgress.set(key, {
arrived: Boolean(prior?.arrived) || blockArrived(block),
left: Boolean(prior?.left) || blockLeft(block),
});
}
const totalTrucks = truckProgress.size;
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
// (or a truck without a saved arrival) starts at the arrival step even when
// other trucks of the booking are already mid-flow or gone.
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
const isExitStep = blockArrived(selectedBlock);
const hasTruckLeft = blockLeft(selectedBlock);
const isEntranceLocked = isExitStep;
const selectedOption = truckSelectOptions.find(
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
);
// Identity comes from the arrival record or the assignment — locked either
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
/** Load a truck into the form: its saved block if any, else its assignment. */
const applyTruckSelection = (plate: string) => {
const block = blockForPlate(savedBlocks, plate);
const option = truckSelectOptions.find(
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
);
loadedPlateRef.current = plate.trim().toUpperCase();
setTruckPlateNumber(plate);
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
setDriverName(block?.driverName || option?.driverName || '');
setDriverLicense(block?.driverLicense || '');
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
setTruckType(block?.truckType || option?.truckType || '');
const loaded = block
? splitContainerNumbers(block.containerNumber)
: (option?.containerNumbers ?? []);
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
};
useEffect(() => {
if (opened) {
loadedPlateRef.current = null;
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
// Initial truck: the caller's prefill, else the first truck still mid-flow
// (arrived but not left) — the operator can switch trucks in the select.
const prefillPlate =
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? truckPrefill.truckPlateNumber
: '';
const blocks = parseInspectionBlocks(item?.notes);
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
// Legacy single-truck bookings stored the truck on the booking columns; a
// comma-joined value means several trucks, so the operator picks instead.
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
const block = blockForPlate(blocks, initialPlate);
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
setTruckPlateNumber(initialPlate);
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(
block?.driverName ||
truckPrefill?.driverName ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
);
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(
block?.truckType ||
truckPrefill?.truckType ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
);
setContainerNumbers(
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
);
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
}
}, [opened, item, truckPrefill]);
// No truck chosen yet and exactly one is assigned — load it. With several
// trucks the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
if (truckSelectOptions.length !== 1) return;
applyTruckSelection(truckSelectOptions[0].value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
// A typed plate that matches a saved arrival reloads that truck's record, so
// the exit step opens with the weigh-in data instead of blank fields.
useEffect(() => {
if (!opened) return;
const key = truckPlateNumber.trim().toUpperCase();
if (!key || loadedPlateRef.current === key) return;
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber]);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
);
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
// Even an unweighed truck records the cargo weight it is holding — the
// selected containers' sum is the net that goes on the exit record.
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (hasTruckLeft) {
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
return;
}
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
// Skipped weighing still records the net from what the truck holds.
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
if (!isExitStep) {
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
toast({
title: 'Truck arrival saved',
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const blob = response.data;
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
toast({
title: 'Release exit paper issued',
description: opened
description: (opened
? 'The PDF opened in a browser tab for printing or saving.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
});
onClose();
} catch (error) {
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
)}
</Alert>
{totalTrucks > 1 && (
<Alert icon={<Truck size={16} />} color="blue" variant="light">
<Group gap="xs">
<Text size="sm">
{totalTrucks} trucks on this booking each is weighed in and out separately.
</Text>
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
{arrivedTrucks}/{totalTrucks} arrived
</Badge>
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
{leftTrucks}/{totalTrucks} left
</Badge>
</Group>
</Alert>
)}
{hasTruckLeft && (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck {truckPlateNumber} has already left its exit record is locked. Pick another
truck to continue the remaining arrivals and exits.
</Text>
</Alert>
)}
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={referenceLocked}
/>
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
label="Truck at the gate"
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
placeholder="Select the assigned truck"
searchable
clearable
// Enabled at arrival so the operator picks which assigned truck came;
// only locked on the exit (leaving) step once identity is captured.
disabled={isEntranceLocked}
data={truckSelectOptions}
disabled={releaseMutation.isPending || downloading}
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
value,
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
}))}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
if (value) applyTruckSelection(value);
}}
/>
)}
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
disabled={hasTruckLeft}
/>
) : (
<Stack gap={6}>
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
<Text size="xs" c="dimmed">
Weighbridge skipped the selected containers' cargo weight is recorded as the net.
</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>

View File

@@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal';
interface WarehouseInfoCardProps {
bookingId: string;
bookingReference?: string;
/**
* Booking payment status. Export cargo is received into the warehouse only
* after the booking is paid — receiving an unpaid booking starts storage and
* GRN against cargo the customer has not settled. Optional so existing callers
* that do not have the booking to hand keep their current behaviour.
*/
paymentStatus?: string | null;
/**
* IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo
* arrives OFF a train, so blocking its receive would strand cargo already at
* the yard.
*/
tradeDirection?: string | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
);
}
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
export function WarehouseInfoCard({
bookingId,
bookingReference,
paymentStatus,
tradeDirection,
}: WarehouseInfoCardProps) {
const [modalOpen, setModalOpen] = useState(false);
const { data, isLoading } = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
const wagon = scheduleView?.wagon;
const isLoadedOrDispatched =
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
// Export only, and only when we were actually told the status — an absent prop
// means the caller cannot answer, and guessing "unpaid" would disable a valid
// action. Mirrors the server guard on receive().
const awaitingPayment =
tradeDirection?.toUpperCase() === 'EXPORT' &&
paymentStatus != null &&
paymentStatus.toUpperCase() !== 'PAID';
return (
<Card withBorder radius="md" padding="lg">
@@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
)}
<Tooltip
label="This booking is already received at the warehouse"
disabled={!latest}
label={
latest
? 'This booking is already received at the warehouse'
: 'This booking is not paid yet — cargo can only be received once payment is settled'
}
disabled={!latest && !awaitingPayment}
withArrow
>
{/* span wrapper so the tooltip still fires on the disabled button */}
@@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
leftSection={<PackagePlus size={16} />}
onClick={() => setModalOpen(true)}
fullWidth
disabled={Boolean(latest)}
disabled={Boolean(latest) || awaitingPayment}
>
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
</Button>

View File

@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
// After the first truck registers, the modal decides per truck whether it is
// arriving or leaving — the item-level label covers both for multi-truck.
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* After the first exit the primary action flips to Deliver, but a
multi-truck booking still weighs its remaining trucks in and out. */}
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
<Button
size="compact-xs"
variant="light"
color="yellow"
loading={busy}
onClick={() => onAdvance(item, 'release')}
>
Truck Arrival / Leaving
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"

View File

@@ -23,18 +23,24 @@ export function WarehouseOpsKpiStrip() {
delta:
data != null ? data.receivedToday - data.receivedYesterday : undefined,
hint: "vs yesterday",
// Exactly the items behind the counter: received today.
href: "/dashboard/warehouse-inventory?receivedToday=1",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
// RECEIVED items with no inspection recorded yet.
href: "/dashboard/warehouse-inventory?pendingInspection=1",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
// Land on the On-site tab — the counter excludes inbound trucks.
href: "/dashboard/trucks-on-site?scope=ON_SITE",
},
{
label: "Items aging (>7d)",
@@ -42,6 +48,7 @@ export function WarehouseOpsKpiStrip() {
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
href: "/dashboard/warehouse-inventory?agingOverDays=7",
},
]}
/>

View File

@@ -82,6 +82,8 @@ export const URL_CONSTANTS = {
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
`/companies/documents/${fileId}/request-change`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
@@ -425,6 +427,9 @@ export const URL_CONSTANTS = {
YARDS: "/yards",
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",
SHIPPING_LINE_BY_ID: (id: string) => `/shipping-lines/${id}`,

View File

@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,16 +8,9 @@ import {
type BookingListFilter,
} from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
const parseApiError = extractErrorMessage;
export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({
@@ -55,21 +47,21 @@ export function useBookingMutations(bookingId: string) {
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to accept booking")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
api.bookings.requestChanges.call({ id: bookingId, note }),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(parseApiError(error, "Failed to request changes")),
});
const staffReject = useMutation({
mutationFn: (reason: string) =>
api.bookings.staffReject.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking rejected"),
onError: () => toast.error("Failed to reject booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const reviewOperation = useMutation({
@@ -90,7 +82,7 @@ export function useBookingMutations(bookingId: string) {
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to generate contract")),
});
const signContract = useMutation({
@@ -101,32 +93,32 @@ export function useBookingMutations(bookingId: string) {
consentText?: string;
}) => bookingsService.signContract(bookingId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to sign contract")),
});
const payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete payment")),
});
const startTransit = useMutation({
mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Marked in transit"),
onError: () => toast.error("Failed to start transit"),
onError: (error) => toast.error(parseApiError(error, "Failed to start transit")),
});
const complete = useMutation({
mutationFn: () => api.bookings.complete.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Booking completed"),
onError: () => toast.error("Failed to complete booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete booking")),
});
const cancel = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking cancelled"),
onError: () => toast.error("Failed to cancel booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
const isPending =

View File

@@ -9,6 +9,7 @@ import {
type ContractListFilter,
type SignContractPayload,
} from "@/services/contracts.service";
import { extractErrorMessage } from "@/utils/errorExtractor";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
return Promise.all([
@@ -144,7 +145,7 @@ export function useContractMutations(contractId: string) {
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")),
});
// Edit THIS contract's document articles (per-contract; never the templates).
@@ -152,20 +153,20 @@ export function useContractMutations(contractId: string) {
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
contractsService.updateContractDocument(contractId, snapshot),
onSuccess: (data) => onSuccess(data, "Contract document updated"),
onError: () => toast.error("Failed to update contract document"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to update contract document")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to request changes")),
});
const reject = useMutation({
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
const approveStep = useMutation({
@@ -182,30 +183,46 @@ export function useContractMutations(contractId: string) {
: "Approval step completed";
onSuccess(data, message);
},
onError: () => toast.error("Failed to approve step"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to approve step")),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
// Per-step rejection by an approver (line staff / director / CEO). Two
// flavours: without returnToStepId it is terminal (REJECTED, customer must
// resubmit); with it the contract is sent back to that earlier approver and
// the chain re-runs from there.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
mutationFn: ({
stepId,
reason,
returnToStepId,
}: {
stepId: string;
reason: string;
returnToStepId?: string;
}) =>
contractsService.rejectStep({ id: contractId, stepId, reason, returnToStepId }),
onSuccess: (data, variables) =>
onSuccess(
data,
variables.returnToStepId
? "Contract sent back in the approval chain"
: "Contract rejected",
),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject step")),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to generate contract")),
});
const signContract = useMutation({
mutationFn: (payload: SignContractPayload) =>
contractsService.signContract(contractId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to sign contract")),
});
const createBooking = useMutation({
@@ -331,7 +348,7 @@ export function useContractClearanceMutations(
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Could not approve all documents")),
});
const uploadOutputDocuments = useMutation({
@@ -341,7 +358,7 @@ export function useContractClearanceMutations(
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.error("Upload failed"),
onError: (error) => toast.error(extractErrorMessage(error, "Upload failed")),
});
const finalizeClearance = useMutation({
@@ -380,7 +397,7 @@ export function useCompleteMilestone(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
},
onError: () => toast.error("Failed to complete milestone"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to complete milestone")),
});
}
@@ -402,7 +419,7 @@ export function useAssignRisk(bookingId: string) {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign risk")),
});
}
@@ -420,7 +437,7 @@ export function useAdviseDuty(bookingId: string) {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to advise duty & tax")),
});
}
@@ -436,7 +453,7 @@ export function useAssignStation(bookingId: string) {
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign station")),
});
}
@@ -455,7 +472,7 @@ export function useUploadGlDocuments(bookingId: string) {
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to upload documents")),
});
}
@@ -483,6 +500,6 @@ export function useReportIncident(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to report incident")),
});
}

View File

@@ -21,6 +21,7 @@ import {
invalidateRuleEngineList,
patchRuleEngineListRecord,
} from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
@@ -58,7 +59,7 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
await invalidateRuleEngineList(qc, resource);
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
},
onError: () => toast.error("Failed to update order"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to update order")),
});
const moveOrder = useMutation({
@@ -273,7 +274,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, created);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to create record")),
});
const update = useMutation({
@@ -289,7 +291,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, updated);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to update record")),
});
const remove = useMutation({
@@ -299,7 +302,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
toast.success("Deleted successfully");
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to delete record")),
});
return { create, update, remove };
@@ -455,7 +459,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to submit rate")),
});
const approve = useMutation({
@@ -465,7 +469,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to approve rate")),
});
return { submit, approve };

View File

@@ -23,6 +23,9 @@ export const queryClient = new QueryClient({
void queryClient.invalidateQueries({ queryKey });
}
},
// Mutation failures are surfaced globally by the axios interceptor in
// auth/http.ts (server-message toast on every non-401 failure), so no
// onError toast here — it would double up.
}),
defaultOptions: {
queries: {

View File

@@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
paymentStatus={booking.paymentStatus}
tradeDirection={booking.tradeDirection}
/>
</Box>
<BookingActionsToolbar

View File

@@ -1,6 +1,7 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Box as BoxIcon,
@@ -22,6 +23,7 @@ import {
Users,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -264,7 +266,8 @@ export default function ContractRequestDetailPage() {
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
@@ -428,6 +431,32 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={18} />}
title="Rejection reason"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestRejectionNote}
</Text>
</Alert>
) : null}
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Sent back in the approval chain"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestSendBackNote}
</Text>
</Alert>
) : null}
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}

View File

@@ -27,11 +27,12 @@ import {
IdCard,
LayoutGrid,
Package,
FilePen,
Paperclip,
Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -46,6 +47,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
RequestDocumentChangeModal,
ResetPasswordAction,
TableCard,
formatBytes,
@@ -179,6 +181,10 @@ export default function CustomerDetailPage() {
const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true;
/** Document the reviewer is asking the customer to correct; null = closed. */
const [changeRequestDoc, setChangeRequestDoc] =
useState<CustomerDocument | null>(null);
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
() => [
{
@@ -355,14 +361,31 @@ export default function CustomerDetailPage() {
{
id: "name",
header: "Document",
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<FileText size={16} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text" truncate>
{row.original.name}
</Text>
</Group>
),
cell: ({ row }) => {
const doc = row.original;
return (
<Stack gap={2}>
<Group gap="sm" wrap="nowrap">
<FileText size={16} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text" truncate>
{doc.name}
</Text>
{doc.reviewStatus === "change_requested" && (
<Badge size="xs" color="orange" variant="light">
Change requested
</Badge>
)}
</Group>
{/* The note is the whole point of the request — show it inline so a
second reviewer sees what was already asked for. */}
{doc.reviewStatus === "change_requested" && doc.reviewNote && (
<Text size="xs" c="dimmed" pl={24}>
{doc.reviewNote}
</Text>
)}
</Stack>
);
},
},
{
id: "code",
@@ -423,11 +446,29 @@ export default function CustomerDetailPage() {
>
<Download size={16} />
</ActionIcon>
{canReview && (
<ActionIcon
component="button"
type="button"
variant="subtle"
color="orange"
aria-label="Request change"
title={
row.original.reviewStatus === "change_requested"
? "Update the requested change"
: "Request a change from the customer"
}
data-stop-row-click
onClick={() => setChangeRequestDoc(row.original)}
>
<FilePen size={16} />
</ActionIcon>
)}
</Group>
),
},
],
[view],
[view, canReview],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -1063,6 +1104,12 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
</Tabs>
<RequestDocumentChangeModal
document={changeRequestDoc}
companyId={company.id}
onClose={() => setChangeRequestDoc(null)}
/>
{viewer}
</PageContainer>
);

View File

@@ -17,6 +17,7 @@ import {
Building2,
CheckCircle2,
Clock,
FilePen,
Hourglass,
Mail,
Phone,
@@ -31,7 +32,6 @@ import { useNavigate } from "react-router-dom";
import {
CompanyStatusBadge,
CompanyTypeBadge,
ProfileChips,
formatDate,
} from "@/components/customers";
@@ -52,14 +52,30 @@ import {
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView = "all" | "pending" | "onboarding" | "active";
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{ status?: CompanyStatus; onboardingCompleted?: boolean }
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
@@ -94,7 +110,9 @@ export default function CustomersPage() {
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.customers.list.queryOptions({ input: { filter } }),
@@ -127,7 +145,6 @@ export default function CustomersPage() {
<Text fw={600} c="edr-text" truncate>
{c.name}
</Text>
<CompanyTypeBadge type={c.type} />
</Group>
<Text size="xs" c="dimmed">
TIN {c.tin}
@@ -141,7 +158,9 @@ export default function CustomersPage() {
{
id: "profiles",
header: "Profiles",
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
cell: ({ row }) => (
<ProfileChips profiles={row.original.companyProfiles} />
),
},
{
id: "status",
@@ -247,9 +266,30 @@ export default function CustomersPage() {
<KpiStrip
items={[
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Companies",
value: stats?.total ?? "—",
icon: Users,
color: "edr-green",
},
{
label: "Active",
value: stats?.active ?? "—",
icon: CheckCircle2,
color: "edr-green",
},
{
label: "Pending",
value: stats?.pending ?? "—",
icon: Clock,
color: "yellow",
},
{
label: "Pending changes",
value: stats?.pendingChanges ?? "—",
icon: FilePen,
color: "yellow",
},
{
label: "Onboarding",
value: stats?.onboarding ?? "—",
@@ -301,6 +341,7 @@ export default function CustomersPage() {
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
@@ -327,39 +368,39 @@ export default function CustomersPage() {
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={980}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
message: "Failed to load customers.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>

View File

@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -51,28 +52,46 @@ interface StatCardProps {
value: string | number;
color?: string;
change?: number;
/** Detail route the card opens. When set the card is a link; otherwise static. */
href?: string;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change, href }: StatCardProps) => {
const card = (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}`, height: '100%' }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
</Stack>
</Card>
);
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
</Group>
</Stack>
</Card>
);
// Wrap in a link to the detail view rather than morphing the Card itself —
// keeps Mantine's Card typing clean. Static when no href.
return href ? (
<Link
to={href}
aria-label={`${label} — view detail`}
className="block h-full cursor-pointer no-underline transition-opacity hover:opacity-90"
>
{card}
</Link>
) : (
card
);
};
export function FleetDashboard() {
const { data: vehicles = [] } = useQuery({
@@ -160,16 +179,16 @@ export function FleetDashboard() {
{/* Primary Metrics */}
<Grid mb="xl">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" href="/dashboard/vehicles" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" href="/dashboard/drivers" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" href="/dashboard/fuel-purchases" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" href="/dashboard/maintenance" />
</Grid.Col>
</Grid>

View File

@@ -19,7 +19,6 @@ import {
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -47,7 +47,7 @@ import {
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string; distanceKm: string };
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
@@ -56,12 +56,12 @@ type RouteFormState = {
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [
{ yardId: "", distanceKm: "0" },
{ yardId: "", distanceKm: "" },
],
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
@@ -163,6 +163,15 @@ export default function RoutesPage() {
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
@@ -206,15 +215,33 @@ export default function RoutesPage() {
[yardsQuery.data],
);
const formTotalKm = useMemo(
() =>
form.milestones.reduce(
(sum, row, index) =>
index === 0 ? sum : sum + Number(row.distanceKm || 0),
0,
),
[form.milestones],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
@@ -234,10 +261,7 @@ export default function RoutesPage() {
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m, index) => ({
yardId: m.yardId,
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
})),
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
@@ -254,7 +278,7 @@ export default function RoutesPage() {
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
milestones: [...current.milestones, { yardId: "" }],
}));
};
@@ -267,11 +291,7 @@ export default function RoutesPage() {
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row, index) => ({
yardId: row.yardId,
distanceKm:
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
})),
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
@@ -284,12 +304,23 @@ export default function RoutesPage() {
});
return;
}
for (let i = 1; i < form.milestones.length; i++) {
const km = Number(form.milestones[i].distanceKm);
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `Enter segment KM for stop ${i + 1}`,
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
@@ -580,8 +611,11 @@ export default function RoutesPage() {
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
@@ -594,16 +628,25 @@ export default function RoutesPage() {
searchable
/>
{index > 0 ? (
<NumberInput
w={120}
label="KM"
min={0}
decimalScale={2}
value={row.distanceKm ? Number(row.distanceKm) : ""}
onChange={(value) =>
setMilestone(index, { distanceKm: String(value ?? "") })
}
/>
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
@@ -619,7 +662,8 @@ export default function RoutesPage() {
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong>
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>

View File

@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
/**
* Format the value must match, checked on submit. The value is upper-cased and
* trimmed before the test, matching the server. Empty optional fields skip it.
*/
pattern?: { regex: RegExp; message: string; uppercase?: boolean };
}
export interface FleetListFilterDef {

View File

@@ -1,5 +1,15 @@
import type { FleetResourceConfig } from "./resources";
/**
* A plate is two or three letters, a hyphen, then two to six digits — ET-9875,
* AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server
* agree on what a plate looks like.
*/
const PLATE_PATTERN = {
regex: /^[A-Z]{2,3}-\d{2,6}$/,
message: "Use letters and numbers like ET-9875 or AA-8642",
};
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
],
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },

View File

@@ -244,7 +244,12 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
config?.formFields.some(
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
@@ -347,6 +352,19 @@ const RuleEngineResourcePage = () => {
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
// Yard-distance endpoints have no country restriction — any yard can pair
// with any other; the other end is just excluded so A↔A can't be entered.
if (field.name === "fromYardId" || field.name === "toYardId") {
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
(yardOptions ?? [])
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
.map(({ label, value }) => ({ label, value })),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
@@ -604,7 +622,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
canManage && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>

View File

@@ -348,6 +348,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
@@ -370,6 +371,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
category: "configuration",
subtitle: "Rail distance between yard pairs — routes read their segment km from here",
searchPlaceholder: "Search by yard name or code...",
supportsSearch: true,
cardTitleKey: "fromYardLabel",
cardSubtitleKey: "toYardLabel",
columns: [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
{ name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" },
{ name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" },
{
name: "distanceKm",
label: "Distance (km)",
type: "number",
required: true,
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
],
},
{
slug: "priority-configs",
label: "Priority Rules",

View File

@@ -36,8 +36,11 @@ import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import {
directionColor,
locomotiveStatusColor,
locomotiveStatusLabel,
trainStatusColor,
trainStatusLabel,
UNFIT_LOCOMOTIVE_STATUSES,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
@@ -131,6 +134,9 @@ export default function TrainBuilderDetailPage() {
const { totals } = composition;
const yard = composition.currentYard;
const blockingLocomotives = composition.locomotives.filter((loco) =>
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
);
return (
<PageContainer>
@@ -180,6 +186,7 @@ export default function TrainBuilderDetailPage() {
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
@@ -234,6 +241,59 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
<Stack gap="xs">
<Text size="sm">
Cannot reactivate {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
not fit for service:{" "}
{blockingLocomotives.map((loco, i) => (
<span key={loco.id}>
{i > 0 ? ", " : ""}
<Text span fw={600} ff="monospace">
{loco.code}
</Text>{" "}
({locomotiveStatusLabel(loco.status)})
</span>
))}
.
</Text>
<Group gap="xs">
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
<Button
size="compact-sm"
variant="subtle"
onClick={() => navigate(`/dashboard/locomotives`)}
>
Go to locomotives
</Button>
</Group>
</Stack>
</Alert>
) : null}
<Group gap="xs">
{composition.locomotives.map((loco) => (
<Badge
key={loco.id}
variant="light"
color={locomotiveStatusColor(loco.status)}
leftSection={<TrainFront size={12} />}
>
{loco.code} · {locomotiveStatusLabel(loco.status)}
</Badge>
))}
</Group>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -47,15 +48,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
No trucks on site.
No trucks assigned or on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={980}>
<Table.ScrollContainer minWidth={1040}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
@@ -69,6 +71,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant={row.status === "ON_SITE" ? "filled" : "light"}
color={row.status === "ON_SITE" ? "edr-green" : "gray"}
>
{row.status === "ON_SITE" ? "On site" : "Inbound"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
@@ -101,9 +113,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{isLongDwell(row.arrivedAt) ? (
{row.arrivedAt == null ? (
<Text size="sm" c="dimmed">
</Text>
) : isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
@@ -124,12 +140,20 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
@@ -137,8 +161,10 @@ export default function TrucksOnSitePage() {
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, source, search]);
}, [trucks, scope, source, search]);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
const edrCount = trucks.length - customerCount;
@@ -146,20 +172,32 @@ export default function TrucksOnSitePage() {
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
subtitle="Customer self-haul and EDR last-mile trucks — assigned (inbound) or arrived, until they leave the yard."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
{ label: `Inbound (${inboundCount})`, value: "INBOUND" },
]}
/>
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
</Group>
<TextInput
size="xs"
w={280}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackageOpen, Search, Truck } from 'lucide-react';
import { PackageOpen, Search, Truck, X } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
// KPI drill-downs arriving from the ops dashboard cards; each shows as a
// dismissible chip so the list can be widened back out in place.
const [quick, setQuick] = useState<
Pick<InventoryFilter, 'receivedToday' | 'pendingInspection' | 'agingOverDays'>
>(() => {
const aging = Number(searchParams.get('agingOverDays'));
return {
receivedToday: searchParams.get('receivedToday') ? true : undefined,
pendingInspection: searchParams.get('pendingInspection') ? true : undefined,
agingOverDays: Number.isFinite(aging) && aging > 0 ? aging : undefined,
};
});
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
[filter, quick, direction, debouncedSearch],
);
const quickChips: Array<{ key: keyof typeof quick; label: string }> = [
...(quick.receivedToday ? [{ key: 'receivedToday' as const, label: 'Received today' }] : []),
...(quick.pendingInspection
? [{ key: 'pendingInspection' as const, label: 'Pending inspection' }]
: []),
...(quick.agingOverDays
? [{ key: 'agingOverDays' as const, label: `In warehouse >${quick.agingOverDays}d` }]
: []),
];
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
}
w={200}
/>
{quickChips.map((chip) => (
<Button
key={chip.key}
size="compact-xs"
variant="light"
rightSection={<X size={12} />}
onClick={() => setQuick((q) => ({ ...q, [chip.key]: undefined }))}
>
{chip.label}
</Button>
))}
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />

View File

@@ -2631,6 +2631,25 @@ export const api = {
],
),
/**
* Ask the customer to correct one document. Invalidates the documents list
* and the company itself, since an open request blocks role approval.
*/
requestDocumentChange: endpoint<
{ companyId: string; fileId: string; note: string },
CustomerDocument
>(
"customers",
"requestDocumentChange",
({ fileId, note }) =>
customersService.requestDocumentChange(fileId, note),
undefined,
({ companyId }) => [
QUERY_KEYS.CUSTOMERS.documents(companyId),
QUERY_KEYS.CUSTOMERS.byId(companyId),
],
),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",

View File

@@ -229,15 +229,26 @@ export const contractsService = {
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
/**
* Reject the current step. Without `returnToStepId` the contract is rejected
* to the customer (terminal). With it, the contract is sent back to that
* earlier approved step and the chain re-runs from there.
*/
rejectStep: ({
id,
stepId,
reason,
returnToStepId,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
returnToStepId?: string;
}) =>
postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), {
reason,
...(returnToStepId ? { returnToStepId } : {}),
}),
// ── Contract document ──
generateContract: (id: string) =>

View File

@@ -164,4 +164,21 @@ export const customersService = {
)
.then((r) => r.data);
},
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads
* this one, but the role cannot be approved until they do.
*/
requestDocumentChange(
fileId: string,
note: string,
): Promise<CustomerDocument> {
return apiClient
.post<CustomerDocument>(
URL_CONSTANTS.COMPANIES.DOCUMENT_REQUEST_CHANGE(fileId),
{ note },
)
.then((r) => r.data);
},
};

View File

@@ -15,6 +15,10 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
currentYardId?: string;
/** Drop locos already coupled to a built train (train-builder picker). */
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
}
export interface Locomotive {
@@ -48,6 +52,8 @@ export const locomotivesService = {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -35,8 +35,9 @@ export interface RouteRecord {
milestones?: RouteMilestone[];
}
/** Segment km are resolved server-side from configured yard distances. */
export interface SaveRoutePayload {
milestones: Array<{ yardId: string; distanceKm?: number }>;
milestones: Array<{ yardId: string }>;
status?: RouteStatus;
}

View File

@@ -86,6 +86,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
@@ -107,6 +108,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
case "yards":
return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id);
case "yard-distances":
return URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCE_BY_ID(id);
case "shipping-lines":
return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id);
case "rates":

View File

@@ -144,6 +144,8 @@ export interface LastMileArrivalTruck {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}
export const warehouseService = {

Some files were not shown because too many files have changed in this diff Show More