mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
Merge remote-tracking branch 'origin/dev' into tests
Merging dev to issuefix branch .
This commit is contained in:
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateYardDistanceDto } from './create-yard-distance.dto';
|
||||
|
||||
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ?? '-')
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -417,12 +417,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 +440,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 +455,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 +479,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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1138,14 +1144,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 +1173,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 +1191,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 +2556,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);
|
||||
@@ -5649,6 +5655,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
} from "./RequestDocumentChangeModal";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,18 +23,23 @@ export function WarehouseOpsKpiStrip() {
|
||||
delta:
|
||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||
hint: "vs yesterday",
|
||||
// The received cargo itself, on the inventory board.
|
||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
|
||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
@@ -42,6 +47,8 @@ export function WarehouseOpsKpiStrip() {
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
// No aging filter on the board; the inventory list is the landing.
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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")),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
tradeDirection={booking.tradeDirection}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -47,15 +47,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 +70,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 +112,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 +139,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
||||
|
||||
export default function TrucksOnSitePage() {
|
||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
||||
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">("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 +154,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 +165,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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}` : ''}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -202,6 +202,11 @@ export interface CompanyListFilter {
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
/**
|
||||
* `true` = only customers with an open profile change request. They are
|
||||
* already `active`, so `status` alone can never surface them.
|
||||
*/
|
||||
hasPendingChangeRequest?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
@@ -222,6 +227,8 @@ export interface CompanyStats {
|
||||
onboarding: number;
|
||||
suspended: number;
|
||||
blacklisted: number;
|
||||
/** Approved customers whose submitted profile edits are awaiting review. */
|
||||
pendingChanges: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -264,6 +271,15 @@ export interface CustomerDocument {
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Reviewer verdict. `change_requested` means the customer has been asked to
|
||||
* re-upload a corrected version and the role cannot be approved until they do;
|
||||
* `null` means nobody has reviewed this document.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
}
|
||||
|
||||
export type CustomerPaymentStatus =
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RuleEngineResourceSlug =
|
||||
| "service-types"
|
||||
| "weight-limit-rules"
|
||||
| "yards"
|
||||
| "yard-distances"
|
||||
| "shipping-lines"
|
||||
| "rates"
|
||||
| "approval-rules";
|
||||
|
||||
@@ -879,9 +879,12 @@ export interface CompositionRemovalEntry {
|
||||
// the train's remaining wagon/weight/length capacity.
|
||||
|
||||
export interface IntercityCapacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
// Each axis can be null when the schedule's train/locomotive has no limit
|
||||
// configured for it (e.g. no max length on the loco) — the API passes the
|
||||
// gap through rather than inventing a number.
|
||||
wagons: number | null;
|
||||
weightTons: number | null;
|
||||
lengthMeters: number | null;
|
||||
}
|
||||
|
||||
export interface IntercityBookingRow {
|
||||
|
||||
@@ -1128,6 +1128,8 @@ export interface WarehouseOpsStats {
|
||||
export interface TruckOnSite {
|
||||
source: "CUSTOMER" | "EDR";
|
||||
assignmentId: string;
|
||||
/** INBOUND = assigned, not yet arrived; ON_SITE = arrived, not yet departed. */
|
||||
status: "INBOUND" | "ON_SITE";
|
||||
plateNumber: string | null;
|
||||
driverName: string | null;
|
||||
truckType: string | null;
|
||||
|
||||
17
apps/edr-freight-web/backoffice/src/utils/errorExtractor.ts
Normal file
17
apps/edr-freight-web/backoffice/src/utils/errorExtractor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Pull the SERVER's actual error message out of a failed request.
|
||||
*
|
||||
* NestJS returns `{ message: string | string[] }`; a class-validator failure is
|
||||
* the array form (joined here). Falls back to the error's own `.message` — the
|
||||
* axios response interceptor (see `auth/http.ts`) already rewrites that to the
|
||||
* server message, so even code paths that never see the raw response body get
|
||||
* the real cause — then to the caller's fallback string.
|
||||
*/
|
||||
export const extractErrorMessage = (err: unknown, fallback: string): string => {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.filter(Boolean).join(", ");
|
||||
if (typeof msg === "string" && msg) return msg;
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return fallback;
|
||||
};
|
||||
@@ -212,23 +212,23 @@ export default function OnboardingWizardDialog({
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
// Finalize: upload per-role license files + company documents, then complete.
|
||||
// Finalize: upload per-role license files, then complete.
|
||||
//
|
||||
// Company documents are deliberately NOT uploaded here. The documents step
|
||||
// uploads them via `onUploadDocuments` and then triggers submit in the same
|
||||
// synchronous `nextStep` call (CompanyProfileForm), so the `setDocumentFiles({})`
|
||||
// that clears them has not re-rendered by the time this mutation's closure
|
||||
// runs — reading `documentFiles` here would re-send the exact same files and
|
||||
// create a duplicate row per document. Licenses have no such auto-upload, so
|
||||
// they are uploaded here.
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const companyId = company?.company?.id;
|
||||
// Per-role business licenses (file model, resource=company_profiles).
|
||||
for (const [profileId, files] of Object.entries(licenseFiles)) {
|
||||
if (files.length > 0) {
|
||||
await companiesService.uploadProfileLicense(profileId, files);
|
||||
}
|
||||
}
|
||||
// Nationality-based company documents (resource=companies).
|
||||
const hasDocs = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (companyId && hasDocs) {
|
||||
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||
}
|
||||
return api.companies.completeOnboarding.call();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -409,6 +409,10 @@ export default function ContractDetailPage() {
|
||||
|
||||
const canSign = contract.status === "CONTRACT_READY";
|
||||
const customsPath = contract.customsClearingEnabled;
|
||||
// Intercity (DOMESTIC) has no customs — the document gate collects the
|
||||
// admin-configured intercity set, reviewed by Operations.
|
||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
|
||||
// Only the NON-customs (Path A) customer books himself — once the contract is
|
||||
// executed after self-clearance. Customs (Path B) bookings are created by
|
||||
// Global Logistics on the customer's behalf, so the customer gets no booking
|
||||
@@ -586,8 +590,8 @@ export default function ContractDetailPage() {
|
||||
onClick={clearanceModal.open}
|
||||
>
|
||||
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "Manage clearance documents"
|
||||
: "Upload clearance documents"}
|
||||
? `Manage ${docNoun}`
|
||||
: `Upload ${docNoun}`}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -852,7 +856,9 @@ export default function ContractDetailPage() {
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{customsPath
|
||||
? "Customs clearance shipment"
|
||||
: "Customs clearance required"}
|
||||
: isIntercity
|
||||
? "Intercity documents required"
|
||||
: "Customs clearance required"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
@@ -862,11 +868,17 @@ export default function ContractDetailPage() {
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||
: "Your documents are cleared. You can now create a shipment booking under this contract."
|
||||
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||
: isIntercity
|
||||
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
|
||||
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
|
||||
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||
</Text>
|
||||
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
|
||||
<Button
|
||||
@@ -1581,7 +1593,7 @@ export default function ContractDetailPage() {
|
||||
onClose={clearanceModal.close}
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Clearance documents
|
||||
{isIntercity ? "Intercity documents" : "Clearance documents"}
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
|
||||
@@ -254,6 +254,14 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{contract.status === "REJECTED" && contract.latestRejectionNote && (
|
||||
<Text
|
||||
fz={12.5}
|
||||
style={{ color: "#B42318", whiteSpace: "pre-wrap", width: "100%" }}
|
||||
>
|
||||
Reason: {contract.latestRejectionNote}
|
||||
</Text>
|
||||
)}
|
||||
{expirySoon && (
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<AlertTriangle size={14} color="#9A6700" />
|
||||
|
||||
@@ -480,13 +480,6 @@ export default function NewContractPage({
|
||||
? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget)
|
||||
: "";
|
||||
|
||||
const onboardingDocs = useMemo(() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
}, [auth.company, auth.activeCompanyProfileId]);
|
||||
|
||||
async function handleContinue() {
|
||||
const fields = contractStepFields[step];
|
||||
if (fields.length > 0) {
|
||||
@@ -580,12 +573,15 @@ export default function NewContractPage({
|
||||
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(serviceType?.includesCustoms && data.customsClearingEnabled
|
||||
? {
|
||||
customsClearingEnabled: true,
|
||||
customsClearingAgent: data.customsClearingAgent || undefined,
|
||||
}
|
||||
: { customsClearingEnabled: false }),
|
||||
// Customs bundling is a property of the chosen service, not of a stored
|
||||
// form flag — derive it here so stale drafts can't misreport it. A
|
||||
// non-bundled contract still records the customer's own clearing agent.
|
||||
...(serviceType?.includesCustoms
|
||||
? { customsClearingEnabled: true }
|
||||
: {
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: data.customsClearingAgent?.trim() || undefined,
|
||||
}),
|
||||
cargoScope,
|
||||
routes,
|
||||
};
|
||||
@@ -787,7 +783,6 @@ export default function NewContractPage({
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
onboardingDocs={onboardingDocs}
|
||||
pricing={
|
||||
pricingData
|
||||
? {
|
||||
|
||||
@@ -289,16 +289,22 @@ export function Step2ServiceType({
|
||||
);
|
||||
}
|
||||
|
||||
// Customs clearance bundling is driven by the service: includesCustoms → the
|
||||
// contract follows Path B (clearance docs after sign); otherwise Path A.
|
||||
if (includesCustoms) {
|
||||
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
} else {
|
||||
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
||||
}, [serviceType, includesFirstMile, includesLastMile, form]);
|
||||
|
||||
// Customs clearance bundling is driven by the service: includesCustoms → the
|
||||
// contract follows Path B (clearance docs after sign); otherwise Path A.
|
||||
// Synced unconditionally — the prev-service guard above skips the very first
|
||||
// selection and restored drafts, which left customsClearingEnabled stale.
|
||||
useEffect(() => {
|
||||
const desired = Boolean(includesCustoms);
|
||||
if (form.getValues("customsClearingEnabled") !== desired) {
|
||||
form.setValue("customsClearingEnabled", desired, { shouldDirty: true });
|
||||
}
|
||||
// A bundled-customs service never carries a customer-named agent.
|
||||
if (desired && form.getValues("customsClearingAgent")) {
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
}
|
||||
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
||||
}, [includesCustoms, form]);
|
||||
|
||||
// A hidden mile must not leak a stale enabled=true into the payload. The
|
||||
// effect above only fires on service change; switching operation type (import
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FileText,
|
||||
MapPin,
|
||||
Package,
|
||||
RotateCcw,
|
||||
Route,
|
||||
Send,
|
||||
Truck,
|
||||
@@ -180,7 +181,6 @@ export function Step8Review({
|
||||
form,
|
||||
direction,
|
||||
referenceData,
|
||||
onboardingDocs = [],
|
||||
pricing,
|
||||
onSaveDraft,
|
||||
onSubmit,
|
||||
@@ -194,7 +194,6 @@ export function Step8Review({
|
||||
setStep?: (step: number) => void;
|
||||
direction: Freight.ScheduleTradeDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
onboardingDocs?: Array<{ name: string; size?: number }>;
|
||||
/** Unit-rate quotation, if the customer has already generated price. */
|
||||
pricing?: { currency: string; lineItems: Freight.ContractUnitRateLineItem[] } | null;
|
||||
onSaveDraft?: () => void;
|
||||
@@ -216,15 +215,46 @@ export function Step8Review({
|
||||
);
|
||||
|
||||
const isGeneralContract = values.contractKind === "general_contract";
|
||||
const isIntercity = values.operationType === "intercity";
|
||||
|
||||
const attachedDocs = Object.entries(
|
||||
(values.documents ?? {}) as Record<string, File | File[] | null>,
|
||||
)
|
||||
.filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v)))
|
||||
.map(([key, v]) => ({
|
||||
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
|
||||
}));
|
||||
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
|
||||
// Customs is a property of the chosen service (bundled → Global Logistics),
|
||||
// not of the stored form flag — a stale draft flag must not misreport it.
|
||||
// Without bundling, the customer may still name their own clearing agent.
|
||||
const ownAgent = values.customsClearingAgent?.trim();
|
||||
const customsValue = isIntercity
|
||||
? "Not applicable — domestic transport"
|
||||
: serviceType?.includesCustoms || values.customsClearingEnabled
|
||||
? "Included — Global Logistics"
|
||||
: ownAgent
|
||||
? `Own agent — ${ownAgent}`
|
||||
: "Not requested";
|
||||
|
||||
// Mirror the step-2 gating: imports never truck the first mile, exports never
|
||||
// truck the last mile, and a service that doesn't bundle a mile can't have it.
|
||||
const firstMileValue =
|
||||
direction === "IMPORT"
|
||||
? "Not applicable for import"
|
||||
: serviceType && !serviceType.includesFirstMile
|
||||
? "Not included in service"
|
||||
: values.firstMile.enabled
|
||||
? `${values.firstMile.pickUpAddress || "Pinned"}${
|
||||
values.firstMile.exactLocation
|
||||
? ` · ${values.firstMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested";
|
||||
const lastMileValue =
|
||||
direction === "EXPORT"
|
||||
? "Not applicable for export"
|
||||
: serviceType && !serviceType.includesLastMile
|
||||
? "Not included in service"
|
||||
: values.lastMile.enabled
|
||||
? `${values.lastMile.deliveryAddress || "Pinned"}${
|
||||
values.lastMile.exactLocation
|
||||
? ` · ${values.lastMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested";
|
||||
|
||||
const cargoValue = (() => {
|
||||
if (values.cargoType === "container") {
|
||||
@@ -372,39 +402,17 @@ export function Step8Review({
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="First mile — pick-up"
|
||||
value={
|
||||
values.firstMile.enabled
|
||||
? `${values.firstMile.pickUpAddress || "Pinned"}${
|
||||
values.firstMile.exactLocation
|
||||
? ` · ${values.firstMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested"
|
||||
}
|
||||
value={firstMileValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="Last mile — delivery"
|
||||
value={
|
||||
values.lastMile.enabled
|
||||
? `${values.lastMile.deliveryAddress || "Pinned"}${
|
||||
values.lastMile.exactLocation
|
||||
? ` · ${values.lastMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested"
|
||||
}
|
||||
value={lastMileValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<FileText size={18} />}
|
||||
label="Customs clearing"
|
||||
value={
|
||||
values.customsClearingEnabled
|
||||
? values.customsClearingAgent
|
||||
? `Agent: ${values.customsClearingAgent}`
|
||||
: "Global Logistics"
|
||||
: "Not requested"
|
||||
}
|
||||
value={customsValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
@@ -416,15 +424,17 @@ export function Step8Review({
|
||||
label="Refrigerated"
|
||||
value={values.isRefrigerated ? "Yes" : "No"}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<FileText size={18} />}
|
||||
label="Documents"
|
||||
value={
|
||||
onboardingDocsCount > 0
|
||||
? `${onboardingDocsCount} attached`
|
||||
: "None attached"
|
||||
}
|
||||
/>
|
||||
{values.cargoType === "container" && (
|
||||
<SummaryItem
|
||||
icon={<RotateCcw size={18} />}
|
||||
label="Empty-container return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With return"
|
||||
: "Without return"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -489,10 +499,6 @@ export function Step8Review({
|
||||
}
|
||||
label="Cargo scope complete"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={onboardingDocsCount > 0}
|
||||
label="Documents attached"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -174,6 +176,17 @@ export default function TabDocuments({
|
||||
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
|
||||
// Company documents render through SmartFileInput, which is keyed by field and
|
||||
// has no per-file review slot. Surfacing the outstanding corrections as one
|
||||
// banner keeps the reviewer's notes visible without reshaping that component.
|
||||
const changeRequested = useMemo(
|
||||
() =>
|
||||
(docsQuery.data ?? []).filter(
|
||||
(d) => d.reviewStatus === "change_requested",
|
||||
),
|
||||
[docsQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="lg">
|
||||
@@ -185,6 +198,30 @@ export default function TabDocuments({
|
||||
Upload and manage required business documents
|
||||
</Text>
|
||||
|
||||
{changeRequested.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
mb="lg"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Some documents need correcting"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm">
|
||||
Re-upload the documents below. Your account cannot be approved
|
||||
until they are corrected.
|
||||
</Text>
|
||||
{changeRequested.map((d) => (
|
||||
<Text key={d.id} size="sm">
|
||||
<strong>{d.name}</strong>
|
||||
{d.reviewNote ? ` — ${d.reviewNote}` : ""}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{docSettingQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
@@ -459,22 +496,48 @@ function ProfileLicenseRow({
|
||||
{formatBytes(f.size)}
|
||||
</Text>
|
||||
)}
|
||||
{/* Without the reason the badge is unactionable — the
|
||||
customer would not know what to change. */}
|
||||
{f.reviewStatus === "change_requested" && f.reviewNote && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{f.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{badge && (
|
||||
{/* A reviewer's correction request outranks the staged-file
|
||||
badge: it is the one state the customer must act on. */}
|
||||
{f.reviewStatus === "change_requested" ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
leftSection={<Clock size={11} />}
|
||||
leftSection={<AlertTriangle size={11} />}
|
||||
style={{
|
||||
backgroundColor: badge.bg,
|
||||
color: badge.fg,
|
||||
backgroundColor:
|
||||
"var(--mantine-color-edr-red-soft-0)",
|
||||
color: "var(--mantine-color-edr-red-0)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
Change requested
|
||||
</Badge>
|
||||
) : (
|
||||
badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
leftSection={<Clock size={11} />}
|
||||
style={{
|
||||
backgroundColor: badge.bg,
|
||||
color: badge.fg,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
<Tooltip label="Replace" withArrow>
|
||||
|
||||
@@ -24,6 +24,14 @@ export interface LicenseFile {
|
||||
mimeType: string;
|
||||
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
|
||||
status: LicenseFileStatus;
|
||||
/**
|
||||
* A reviewer's verdict on this specific document. `change_requested` means the
|
||||
* customer must upload a corrected version before the role can be approved —
|
||||
* orthogonal to `status`, which tracks the staged add/remove workflow.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalProfileResponse {
|
||||
@@ -121,6 +129,13 @@ export interface CompanyDocument {
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
/**
|
||||
* `change_requested` means a reviewer has asked for a corrected version of
|
||||
* this document; the role cannot be approved until it is re-uploaded.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
/** A single onboarding document field, as resolved and described by the backend. */
|
||||
|
||||
@@ -57,4 +57,7 @@ RUN addgroup --system --gid 1001 nodejs \
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 4000
|
||||
CMD ["node", "dist/main.js"]
|
||||
# --enable-source-maps: translate stack-trace frames from dist/*.js back to
|
||||
# src/*.ts using the .js.map files nest build emits (tsconfig sourceMap:true).
|
||||
# Without it Node reports compiled JS line numbers, not TypeScript source.
|
||||
CMD ["node", "--enable-source-maps", "dist/main.js"]
|
||||
|
||||
@@ -674,6 +674,18 @@ export interface IContract extends BaseEntity {
|
||||
* exactly what to fix before resubmitting.
|
||||
*/
|
||||
latestChangeRequestNote?: string | null;
|
||||
/**
|
||||
* Body of the latest REJECTION review note (detail response only, when
|
||||
* status is REJECTED). Shows staff and customer why the contract was
|
||||
* rejected.
|
||||
*/
|
||||
latestRejectionNote?: string | null;
|
||||
/**
|
||||
* Body of the latest send-back STAFF_NOTE (detail response only, while the
|
||||
* contract is PENDING_APPROVAL and no step has acted since the send-back).
|
||||
* Tells the returned-to approver why the chain came back to them.
|
||||
*/
|
||||
latestSendBackNote?: string | null;
|
||||
clearanceStatus: ContractClearanceStatus;
|
||||
clearanceCycleNumber: number;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user